From 2b4b194b37b81ddd887457579267431123651d2d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:19:04 +0200 Subject: [PATCH 01/93] Let a transport say what it knows about an action it listed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/mcp.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 23e5599d7..021459c66 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -85,6 +85,25 @@ export type McpTool = { inputSchema: Record; }; +/** + * A tool as a transport listed it, including anything that transport happens to know about it. + * + * Three optional fields rather than a separate type per transport, so `refreshTools` reads + * `tool.effect` with no cast and no `"effect" in tool` sniffing. Optional because most transports + * know none of it: an MCP server publishes no effect and no version, and a field it always left + * undefined would be an invitation to read it as meaning something. + * + * `McpTool` stays exactly what a `tools/list` answer contains, because that is what it is for. + */ +export type ListedTool = McpTool & { + /** What the vendor said this action does, when it said anything. */ + effect?: "read" | "write"; + /** Whether the vendor marked it as destroying something. */ + destructive?: boolean; + /** The vendor's version string, when calling the action requires one. */ + version?: string; +}; + export class McpServerError extends Error { constructor(message: string) { super(message); From 80a9e89cc5ec316fe9090cf5dbff5850d0c01d66 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:19:08 +0200 Subject: [PATCH 02/93] Record what a listed action does and which version it is Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/db/schema/plugins.ts | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 2c887bbd2..39666cf9f 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -105,6 +105,53 @@ export const mcpTools = pgTable( description: text("description").notNull().default(""), /** The tool's own JSON Schema, passed to the model unchanged. */ inputSchema: jsonb("input_schema").notNull().default({}), + /** + * What this action does, as the vendor itself described it, or null when nothing said. + * + * Recorded here rather than derived per call because the source is the listing: Composio labels + * every action, and those labels arrive with the tool list and nowhere else. A hand-written write + * list per app — which is what {@link CatalogueEntry.writeTools} is — cannot be kept for a + * catalogue of several hundred apps that changes weekly, and a list naming only the actions + * somebody thought of reads as a guard while behaving like a gap. + * + * PLAIN TEXT RATHER THAN AN ENUM, deliberately. The value is somebody else's vocabulary, so a + * database enum would need a migration every time a vendor invents a label, and the migration + * would be the thing standing between a refresh and a correct classification. `classifyTool` + * defends instead: only the exact string `read` produces a read, so an unrecognised value fails + * closed. Same reasoning as `mcp_servers.provenance`, which is text for the same reason. + * + * NULLABLE, AND NOT DEFAULTED TO "write". Every row that already exists was listed before this + * column did, and a default would reclassify every Notion read as a write when the migration ran. + * Null means "nothing said", and the classifier decides that means write. + */ + effect: text("effect"), + /** + * Whether the vendor marked this action as destroying something. + * + * Separate from {@link mcpTools.effect} rather than a third value in it, so the rule engine keeps + * the two values every existing policy is written against and nobody's rules need migrating. It + * is recorded now because the confirmation card is what needs it, and re-listing every app later + * to backfill a column is worse than carrying it from the start. + * + * `false` for an action nothing said about — the same fail-closed direction as `effect` without + * claiming a vendor said something it did not. An unclassified action is already gated as a + * write; marking it destructive as well would paint every ordinary write as dangerous and teach + * an approver to click through the colour. + */ + destructive: boolean("destructive").notNull().default(false), + /** + * The vendor's version for this action, as the listing gave it — `20260903_00` and the like. + * + * NOT OPTIONAL BOOKKEEPING. Composio refuses to execute an action without a specific version, + * and refuses the word `latest` too, so this column is what makes a call possible at all. It is + * stored rather than fetched per call because it arrives free with the listing and fetching it + * would be a second round trip on every single call. + * + * Null for every other transport, which publishes no such thing, and for rows listed before this + * column existed. The Composio transport treats a missing version as a reason to refuse rather + * than a reason to guess — a guessed version is a call against an action's other behaviour. + */ + version: text("version"), createdAt: createdAt(), }, (table) => [primaryKey({ columns: [table.serverId, table.name] })], From 8ed87ed9eb42dd56f7577be062a443bc4df241d0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:19:52 +0200 Subject: [PATCH 03/93] Add the Composio SDK, for the live test to call the real thing with Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- bun.lock | 27 ++++++++++++++++++++------- server/package.json | 1 + 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index b116c2a30..bf28de4f6 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -264,6 +265,12 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@composio/client": ["@composio/client@0.1.0-alpha.76", "", {}, "sha512-MXC5JGRVdiQ4EgLricy9o/mqBa1+1T7wHFZ6Q4ZJkrjzZqOMvxTgy21Zlb5J/1oGkB2bg9UDzpH8PkXCp/D4zA=="], + + "@composio/core": ["@composio/core@0.18.1", "", { "dependencies": { "@composio/client": "0.1.0-alpha.76", "@composio/json-schema-to-zod": "0.3.2", "@types/json-schema": "^7.0.15", "is-fs-case-sensitive": "^2.0.0", "openai": "^7.2.0", "picocolors": "^1.1.1", "pusher-js": "^8.6.0", "semver": "^7.8.5", "undici": "^7.29.0", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-VEg6F92cMG/4uhRfX6O4SRd+0Pnd4L67tF+Az/EXQ/+aPfDasW5GMTzkiSUSArGDyiMfEsLUYaTYizfnEw2usw=="], + + "@composio/json-schema-to-zod": ["@composio/json-schema-to-zod@0.3.2", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "dequal": "^2.0.3" }, "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-TUUzu4uQH6esokVd6LD0SK4oIz8WaYRtU0cnuj0rmxbiHS0VbhN1qdvj2gIvrIWDdNyvQUIalO63YT/lNx46Gw=="], + "@copilotkit/a2ui-renderer": ["@copilotkit/a2ui-renderer@1.70.1", "", { "dependencies": { "@a2ui/web_core": "0.10.4", "clsx": "^2.1.1", "lit": "^3.3.2", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["react", "react-dom"] }, "sha512-YLNNst0ll2A5zjjZHgPSWqLxKOR2b8e5j4IZDnN0lwtJVgRV1NBd2Rzp7XGb+uU2+Kq1jOF9rCGfKBPuw9JtGQ=="], "@copilotkit/aimock": ["@copilotkit/aimock@1.39.0", "", { "peerDependencies": { "jest": ">=29", "vitest": ">=3" }, "optionalPeers": ["jest", "vitest"], "bin": { "aimock": "dist/aimock-cli.js", "llmock": "dist/cli.js" } }, "sha512-AWw4vmW2hBchHoggh0G4McWGmGZD6wtXAehL6K5ncWF5lVIjlv++bPmxmRwrpQCi/K4/xK10N9Zp9srJYipEJw=="], @@ -1428,6 +1435,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fs-case-sensitive": ["is-fs-case-sensitive@2.0.0", "", {}, "sha512-JoCsyGITdYPM+pUbeMQ4IiEuQ4wjPdeWORlG7n644isewbcxiQIr+9gmF5k7UabTP/jLBRkbgydEamR7JZBKHA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], @@ -1872,6 +1881,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "pusher-js": ["pusher-js@8.6.0", "", { "dependencies": { "tweetnacl": "^1.0.3" } }, "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1988,7 +1999,7 @@ "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], @@ -2118,6 +2129,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "type-graphql": ["type-graphql@2.0.0-rc.1", "", { "dependencies": { "@graphql-yoga/subscription": "^5.0.0", "@types/node": "*", "@types/semver": "^7.5.6", "graphql-query-complexity": "^0.12.0", "semver": "^7.5.4", "tslib": "^2.6.2" }, "peerDependencies": { "class-validator": ">=0.14.0", "graphql": "^16.8.1", "graphql-scalars": "^1.22.4" }, "optionalPeers": ["class-validator"] }, "sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], @@ -2266,8 +2279,14 @@ "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@copilotkit/a2ui-renderer/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@copilotkit/channels-slack/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2366,8 +2385,6 @@ "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], - "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -2452,8 +2469,6 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - "jsonwebtoken/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2544,8 +2559,6 @@ "style-to-js/style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "type-graphql/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "unified/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], diff --git a/server/package.json b/server/package.json index 02eb051bc..75153261f 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", From c470dd7080f06ca5adf81ec2cf893c1b702a2557 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:20:55 +0200 Subject: [PATCH 04/93] Believe what a vendor said an action does, and treat silence as a write Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/catalogue.ts | 18 ++++++++++- server/tests/composio-classify.test.ts | 44 ++++++++++++++++++++++++++ server/tests/plugin-catalogue.test.ts | 19 +++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 server/tests/composio-classify.test.ts diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index b8445bfce..ab44f70be 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -374,11 +374,27 @@ export function classifyTool( entry: CatalogueEntry | null, toolName: string, advertised: boolean, + /** + * What the vendor said about this action when it was listed, or null when nothing did. + * + * Consulted BEFORE the entry's write list, because it is the better source and the only one that + * can exist for a broker's catalogue: Composio labels every one of Gmail's sixty-three actions, and + * no reviewed list here could keep pace with several hundred apps that change weekly. + * + * Only the exact string `read` produces a read. A recorded write, an unrecognised value, a + * different case, an empty string and null are all writes, so a column somebody typed into by hand, + * or a label a vendor adds later that this code has never heard of, cannot widen what a Bot may do + * unasked. + */ + recorded?: string | null, ): "read" | "write" { + // A name the server never listed came from a model, and nothing reviewed says it only reads — + // checked first, so a recorded effect cannot rescue a name that was never advertised. + if (!advertised) return "write"; + if (recorded) return recorded === "read" ? "read" : "write"; // A server an administrator added by URL has no reviewed tool catalogue behind it, so nothing here // can say a tool of theirs only reads. Everything it offers is a write. if (!entry) return "write"; - if (!advertised) return "write"; return entry.writeTools.includes(toolName) ? "write" : "read"; } diff --git a/server/tests/composio-classify.test.ts b/server/tests/composio-classify.test.ts new file mode 100644 index 000000000..28d08f7ae --- /dev/null +++ b/server/tests/composio-classify.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { classifyTool } from "../src/plugins/catalogue"; + +/** + * What an action does, when the vendor said so and when nobody did. + * + * The property under test is the direction of the failure. A recorded `read` is the only input that + * can produce a read; everything else — a recorded write, an unrecognised value, null, an empty + * string — is a write. That asymmetry is the point: an action wrongly gated as a write costs a + * confirmation, and one wrongly waved through as a read costs somebody's mailbox. + */ +describe("classifyTool with a recorded effect", () => { + test("a recorded read is a read", () => { + expect(classifyTool(null, "GMAIL_FETCH_EMAILS", true, "read")).toBe("read"); + }); + + test("a recorded write is a write", () => { + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "write")).toBe("write"); + }); + + test("no recorded effect is a write, not a read", () => { + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, null)).toBe("write"); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, undefined)).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "")).toBe("write"); + }); + + test("a value nothing recognises is a write", () => { + // A future label, a typo, or a column somebody wrote by hand. None is a licence to read. + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "readonly")).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "destructive")).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "READ")).toBe("write"); + }); + + test("a recorded read cannot rescue an action the server never advertised", () => { + // The name came from somewhere other than a listing, so no recorded effect is about it. + expect(classifyTool(null, "GMAIL_INVENTED", false, "read")).toBe("write"); + }); +}); diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index d6b533a4a..fc3f39d48 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -543,3 +543,22 @@ describe("which credential a curated server is given", () => { ).toBeNull(); }); }); + +test("a curated entry keeps classifying from its write list when nothing was recorded", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + + // The behaviour that shipped before the column existed, unchanged for every existing row. + expect(classifyTool(notion, "notion-fetch", true)).toBe("read"); + expect(classifyTool(notion, "notion-update-page", true)).toBe("write"); +}); + +test("a recorded write overrides a curated entry that omits the action", () => { + const notion = catalogueEntry("notion"); + if (!notion) return; + + // The write list is known-incomplete. A vendor saying an action writes settles it, and the list + // being out of date stops mattering. + expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); +}); From ef219b7258f04850321701a605ea7d3ffc7013e8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:27:05 +0200 Subject: [PATCH 05/93] Keep a Composio connection after its person is gone Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/db/schema/plugins.ts | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 39666cf9f..db1e8f82d 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -157,6 +157,53 @@ export const mcpTools = pgTable( (table) => [primaryKey({ columns: [table.serverId, table.name] })], ); +/** + * One person's Composio connection to one app. + * + * WHY THIS IS NOT `mcp_user_credentials`. That table's whole guarantee is that a row means real held + * access: it points at a vault row, not-null, and the vault is what offboarding scans. Composio holds + * the account, so there is no secret to point at and none to scan for — and `retireConnectionsFor` + * deliberately reads the VAULT rather than the join table, because the join row is deleted along with + * the person while the vault row survives. Putting a Composio connection there would mean removing + * somebody deletes the only record of it, leaving their mailbox connected at Composio with nothing + * left to revoke it by, while an administrator has been told they removed it. + * + * So `user_id` is plain text with NO foreign key and no cascade. The row outliving the person is the + * point, not an oversight: it is the only thing that lets offboarding say "this person had Gmail + * connected, tell Composio to drop it". A scope column would be a lie — Composio returns no scope we + * see, and the column on the other table exists precisely to record what the vendor said it granted — + * so there is none. + * + * A CACHE, NOT THE TRUTH. Composio is authoritative about whether a connection is live; this row + * exists so the settings page can be drawn without a network call per row, and so offboarding has + * something to iterate. A call against an app the person never connected fails at Composio, and that + * refusal is the answer rather than this table's absence. + */ +export const composioConnections = pgTable( + "composio_connections", + { + /** The Composio app slug, lower case, as their directory spells it: `gmail`, `slack`. */ + toolkit: text("toolkit").notNull(), + /** + * The person, as `users.id`. + * + * The same value sent to Composio as the identity a call runs under, so the two cannot drift: + * what this row says somebody connected is what a call will act as. + */ + userId: text("user_id").notNull(), + /** When they connected, shown on their own settings page. */ + connectedAt: timestamp("connected_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: updatedAt(), + }, + (table) => [ + primaryKey({ columns: [table.toolkit, table.userId] }), + // "What has this person connected" is the settings page's only query, and offboarding's. + index("composio_connections_user_idx").on(table.userId), + ], +); + /** * One person's grant on one MCP server: the row that makes a Bot answer as the asker. * From 4e003dec97df6612542120b61dec7cb80234cafc Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:28:48 +0200 Subject: [PATCH 06/93] Reach a Composio app as the person asking, at the version that was listed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 273 +++++++++++++++++ server/tests/composio-transport.test.ts | 386 ++++++++++++++++++++++++ 2 files changed, 659 insertions(+) create mode 100644 server/src/plugins/composio.ts create mode 100644 server/tests/composio-transport.test.ts diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts new file mode 100644 index 000000000..72d237c70 --- /dev/null +++ b/server/src/plugins/composio.ts @@ -0,0 +1,273 @@ +import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; + +/** + * The Composio transport: an app somebody enabled, reached as the person asking. + * + * WHAT MAKES THIS DIFFERENT FROM THE OTHER TRANSPORTS. `mcp` dials somebody else's server and + * `google-drive-rest` dials Google; both answer to a credential, and whose it is was settled before + * the connection was built. `builtin-routines` has no credential at all. This one has a credential + * that is not the answer: the deployment holds ONE Composio key, and which person's Gmail it opens is + * decided by a user id we send alongside it. So the ACTOR is the authorization here, exactly as it is + * for Routines, and for the same reason {@link callTool} refuses a run that is not attributed to + * anybody. + * + * THE USER ID IS NEVER AN ARGUMENT. It comes off the connection, which the call path derives from the + * session. A model that could name a user id could open somebody else's mailbox, and that is not + * hypothetical: it is the defect OpenTag shipped and fixed three separate times. Nothing below reads + * `args` looking for an identity, which is what makes it structurally impossible rather than merely + * checked. + * + * It implements the same interface as the other three, as module-level exports, because that is the + * shape {@link ./transport} resolves: a `TransportKind` maps to a MODULE. Which is also why the client + * arrives through {@link useComposioClient} rather than a constructor — the registry is built at + * import time, long before `index.ts` has configuration. + */ + +/** + * The argument key the call path uses to hand this transport the recorded version. + * + * A reserved key on `args` rather than a fourth parameter on the shared `callTool` signature, because + * that signature is MCP's own and three other transports implement it — widening it for one vendor's + * requirement would put a field on every transport that only one of them can use. Stripped before + * anything reaches Composio, and asserted stripped, so a vendor never sees a key it did not publish. + * + * Underscored so it cannot collide with a real argument name: Composio's schemas are snake_case. + */ +export const VERSION_ARG = "__version"; + +/** One action, as much of Composio's listing as anything here reads. */ +export type ComposioAction = { + slug: string; + description?: string; + /** JSON Schema, as they spell it. Absent for the occasional action that publishes none. */ + inputParameters?: Record; + /** Behaviour labels mixed in with topical ones. See {@link effectOf}. */ + tags?: string[]; + /** The version calling this action requires — `20260903_00` and the like. */ + version?: string; +}; + +/** + * What this module needs of Composio, and nothing more. + * + * A narrow projection rather than their client, so a test satisfies it with two functions and the + * SDK's shape is somebody else's problem in exactly one place: the adapter that installs the real one. + * + * `execute` RESOLVES OR THROWS, with no error field to check. That is not a simplification — it is + * what the live API does, confirmed by calling it. An earlier draft of this module checked a + * `{ data, error }` shape that never occurs, so every stubbed test passed against a fiction. + */ +export type ComposioActions = { + listActions(toolkit: string): Promise; + execute( + slug: string, + userId: string, + version: string, + args: Record, + ): Promise; +}; + +let installed: ComposioActions | null = null; + +/** + * Hand this module its client, once, from the place that reads configuration. + * + * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that + * installs a stub has to be able to take it back out. It is also the unconfigured state — a + * deployment with no Composio key installs nothing, and every function here answers emptily or + * refuses rather than failing, so an app nobody configured is absent rather than broken. + */ +export function useComposioClient(client: ComposioActions | null): void { + installed = client; +} + +/** The tool list needs no credential: Composio publishes an action's schema to anybody. */ +export const listNeedsCredential = false; + +/** + * Which app this connection is about. + * + * The slug lives in the url — `composio://gmail` — rather than in a column of its own, because the url + * is the field every transport already gets and `effectiveUrl` already owns. Null for anything that is + * not one of ours, so a misrouted connection lists nothing instead of asking Composio about a + * hostname. + */ +export function toolkitOf(url: string): string | null { + const prefix = "composio://"; + if (!url.startsWith(prefix)) return null; + const slug = url.slice(prefix.length).replace(/\/+$/, "").trim(); + return slug === "" ? null : slug; +} + +/** + * What an action does, from the labels Composio publishes with it. + * + * SIX LABELS, AND ONLY TWO DECIDE ANYTHING. `readOnlyHint` is the one thing that can produce a read. + * `destructiveHint` produces a destructive write. `createHint` and `updateHint` are writes, which is + * also what an unlabelled action is, so reading them buys nothing over the default. `idempotentHint` + * and `openWorldHint` say nothing about effect — DELETE is idempotent, so treating idempotence as + * safety would wave through exactly the calls worth asking about. + * + * ANYTHING UNLABELLED IS A WRITE. Measured across Gmail, Linear, Calendar, Notion and Slack, every + * action carried at least one label, so this is a guard against the future rather than the present: an + * app that labels nothing, or a label added later that this code has never heard of, must land on + * write. The opposite default would silently classify new actions as safe. + * + * DESTRUCTIVE WINS OVER READ-ONLY. Both at once is somebody else's bug, and the strict reading is the + * only safe one. + */ +export function effectOf(tags: readonly string[] | undefined): { + effect: "read" | "write"; + destructive: boolean; +} { + const labels = new Set(tags ?? []); + if (labels.has("destructiveHint")) return { effect: "write", destructive: true }; + if (labels.has("readOnlyHint")) return { effect: "read", destructive: false }; + return { effect: "write", destructive: false }; +} + +/** + * Every action this app publishes, in the shape a `tools/list` answer has, plus what we know about it. + * + * An action with no schema is still listed, with an open one. The vendor is the right party to reject a + * bad argument, and an action silently missing from the list reads to an administrator as an app that + * does not have it. + */ +export async function listTools(connection: { + url: string; +}): Promise { + const toolkit = toolkitOf(connection.url); + if (!toolkit || !installed) return []; + + const actions = await installed.listActions(toolkit); + + return actions.map((action) => { + const { effect, destructive } = effectOf(action.tags); + return { + name: action.slug, + description: action.description ?? "", + inputSchema: action.inputParameters ?? {}, + effect, + destructive, + ...(action.version ? { version: action.version } : {}), + }; + }); +} + +/** + * The one sentence in a thrown Composio error that is worth showing anybody. + * + * WHY THIS IS A FUNCTION AND NOT AN INLINE READ. The top-level message is "Error executing the tool + * GMAIL_FETCH_EMAILS", which names nothing a reader could act on. The useful sentence — "No connected + * account found for user ID … for toolkit gmail" — is nested two levels inside `cause`, beside the + * entire HTTP response: headers, trace ids, rate-limit counters. So this reaches in for the sentence + * and takes nothing else, because the alternative is somebody's request id in a model's context and + * an audit row the size of a response dump. + * + * openbot already had this lesson from Drive, where a generic message cost a round of probing and the + * vendor's own "The caller does not have permission" named the problem immediately. + * + * Null when there is no such sentence, so the caller falls back to the thrown message rather than + * inventing one. + */ +export function vendorSentence(error: unknown): string | null { + const cause = (error as { cause?: unknown } | null | undefined)?.cause; + const outer = (cause as { error?: unknown } | null | undefined)?.error; + const inner = (outer as { error?: unknown } | null | undefined)?.error; + const message = (inner as { message?: unknown } | null | undefined)?.message; + return typeof message === "string" && message.trim() !== "" + ? message + : null; +} + +const failure = (message: string): McpCallResult => ({ + text: message, + isError: true, + truncated: false, +}); + +/** + * What the model reads, capped visibly. + * + * The same cap the MCP transport applies and for the same reason: a tool result goes straight into a + * model's context, so an unbounded one is somebody else's server deciding how much of our context + * window to spend. Truncated visibly, never silently. An empty answer is stated in words rather than + * returned empty — an empty string reads as "the action had nothing to say" rather than "there is + * nothing there", and a model closes that gap from memory. + */ +function resultOf(data: unknown): McpCallResult { + const text = + typeof data === "string" ? data : JSON.stringify(data ?? null, null, 2); + const truncated = text.length > MAX_RESULT_CHARS; + if (!truncated && (text === "" || text === "null")) { + return { text: "The action returned nothing.", isError: false, truncated: false }; + } + return { + text: truncated ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` : text, + isError: false, + truncated, + }; +} + +/** + * Call one action, in the account of the person this run belongs to. + * + * `args` is passed through with only the reserved version key removed, and is never read for an + * identity. See the module comment: that is the property, and it holds because there is no line here + * that could break it. + * + * A failure comes back as a result rather than a throw, matching `builtin-routines`. The model is + * mid-run with a person waiting; an exception ends the turn with nothing said, and the refusal is in + * the audit trail either way. + */ +export async function callTool( + connection: { url: string; actorId?: string }, + toolName: string, + args: Record, +): Promise { + const userId = connection.actorId?.trim(); + if (!userId) { + return failure( + "This action runs in the account of the person asking, and this run is not attributed to anybody.", + ); + } + + const toolkit = toolkitOf(connection.url); + if (!toolkit) { + return failure(`${connection.url} does not name a Composio app.`); + } + if (!installed) { + return failure( + "Composio is not configured for this deployment, so this action cannot be called.", + ); + } + + const { [VERSION_ARG]: rawVersion, ...rest } = args; + const version = typeof rawVersion === "string" ? rawVersion.trim() : ""; + if (!version) { + /* + * Refused rather than guessed. Composio will not execute an action without a specific version and + * rejects `latest`, so there is no default to fall back on — and a version invented here would be + * a call against some other revision of the action, whose arguments and behaviour are not the ones + * that were listed, classified and granted. + * + * In practice this means the app's tool list has not been refreshed since the version column + * existed, which is an operator's one-click fix rather than anything a person asking can do. + */ + return failure( + `${toolName} has no recorded version, so it cannot be called. Refresh this app's tools on its Plugins page and try again.`, + ); + } + + try { + return resultOf(await installed.execute(toolName, userId, version, rest)); + } catch (error) { + // The vendor's own sentence when there is one, because a generic message costs a diagnosis. + return failure( + vendorSentence(error) ?? + (error instanceof Error + ? error.message + : "Composio did not answer this action."), + ); + } +} diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts new file mode 100644 index 000000000..bff481f1e --- /dev/null +++ b/server/tests/composio-transport.test.ts @@ -0,0 +1,386 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + type ComposioActions, + callTool, + effectOf, + listNeedsCredential, + listTools, + toolkitOf, + useComposioClient, + vendorSentence, +} from "../src/plugins/composio"; + +/** + * The Composio transport's boundary, asserted with no network and no database. + * + * What is under test is the boundary rather than the SDK: which app a connection names, whose id a + * call is attributed to, what a label means, which version is sent, and what a refusal reads as. The + * client arrives through {@link useComposioClient}, which is the only seam the module has — + * `transportFor` resolves a kind to a MODULE, so there is no constructor to pass one to. Same shape + * as `builtin-routines`. + * + * The security property this file exists for is the attribution one: the user id comes off the + * connection and never out of the arguments a model produced. A model that could name a user id + * could open somebody else's mailbox. + */ + +afterEach(() => useComposioClient(null)); + +type Recorded = { slug: string; userId: string; version: string }; + +function recording(answers: Partial = {}): { + client: ComposioActions; + calls: Recorded[]; +} { + const calls: Recorded[] = []; + return { + calls, + client: { + listActions: answers.listActions ?? (async () => []), + execute: + answers.execute ?? + (async (slug, userId, version) => { + calls.push({ slug, userId, version }); + return { ok: true }; + }), + }, + }; +} + +const GMAIL_READ = { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputParameters: { type: "object", properties: { query: { type: "string" } } }, + tags: ["readOnlyHint", "important"], + version: "20260903_00", +}; + +describe("which app a connection names", () => { + test("the app slug comes off the url", () => { + expect(toolkitOf("composio://gmail")).toBe("gmail"); + expect(toolkitOf("composio://gmail/")).toBe("gmail"); + }); + + test("anything that is not a composio url names no app", () => { + expect(toolkitOf("https://mcp.notion.com/mcp")).toBeNull(); + expect(toolkitOf("composio://")).toBeNull(); + expect(toolkitOf("")).toBeNull(); + }); +}); + +describe("what a label means", () => { + test("read-only is a read", () => { + expect(effectOf(["readOnlyHint", "openWorldHint", "gmail"])).toEqual({ + effect: "read", + destructive: false, + }); + }); + + test("destructive is a destructive write", () => { + expect(effectOf(["destructiveHint", "important"])).toEqual({ + effect: "write", + destructive: true, + }); + }); + + test("create and update are writes that are not destructive", () => { + expect(effectOf(["createHint", "openWorldHint"])).toEqual({ + effect: "write", + destructive: false, + }); + expect(effectOf(["updateHint", "labels", "inbox"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("idempotent is not a read, because deleting is idempotent", () => { + expect(effectOf(["idempotentHint", "openWorldHint"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("no label at all is a write", () => { + // Measured across five apps and never seen, so this branch guards the future rather than the + // present: an app that labels nothing, or a label added later, must land on write. + expect(effectOf([])).toEqual({ effect: "write", destructive: false }); + expect(effectOf(undefined)).toEqual({ effect: "write", destructive: false }); + expect(effectOf(["gmail", "inbox"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("destructive wins over read-only when both are present", () => { + // Contradictory labels are somebody else's bug, and the safe reading is the strict one. + expect(effectOf(["readOnlyHint", "destructiveHint"])).toEqual({ + effect: "write", + destructive: true, + }); + }); +}); + +describe("finding the vendor's own sentence", () => { + test("the sentence nested inside the cause is what comes out", () => { + // The real shape, copied from a live failure. The top-level message is useless. + const error = Object.assign(new Error("Error executing the tool X"), { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: { + error: { + message: "No connected account found for user ID u1 for toolkit gmail", + code: 1810, + }, + }, + }, + }); + + expect(vendorSentence(error)).toBe( + "No connected account found for user ID u1 for toolkit gmail", + ); + }); + + test("an error with no such sentence yields nothing rather than a guess", () => { + expect(vendorSentence(new Error("boom"))).toBeNull(); + expect(vendorSentence({ cause: { error: {} } })).toBeNull(); + expect(vendorSentence(undefined)).toBeNull(); + }); +}); + +describe("listing an app's actions", () => { + test("listing needs no credential", () => { + expect(listNeedsCredential).toBe(false); + }); + + test("an action arrives with its schema, its effect and its version", async () => { + const { client } = recording({ + listActions: async (toolkit) => { + expect(toolkit).toBe("gmail"); + return [GMAIL_READ]; + }, + }); + useComposioClient(client); + + expect(await listTools({ url: "composio://gmail" })).toEqual([ + { + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + }, + effect: "read", + destructive: false, + version: "20260903_00", + }, + ]); + }); + + test("an action with no schema is still listed, with an open one", async () => { + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_ODD", tags: ["updateHint"], version: "20260903_00" }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + // Offered rather than dropped: the vendor is the right party to reject a bad argument, and a + // silently missing action reads to an administrator as an app that does not have it. + expect(tool?.name).toBe("GMAIL_ODD"); + expect(tool?.inputSchema).toEqual({}); + expect(tool?.effect).toBe("write"); + }); + + test("listing a url that names no app is empty rather than an error", async () => { + useComposioClient(recording().client); + expect(await listTools({ url: "https://example.com" })).toEqual([]); + }); + + test("listing with no client installed is empty rather than a crash", async () => { + expect(await listTools({ url: "composio://gmail" })).toEqual([]); + }); +}); + +describe("calling one action", () => { + test("the call runs as the connection's actor, at the recorded version", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { query: "is:unread", __version: "20260903_00" }, + ); + + expect(calls).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + userId: "user_asker", + version: "20260903_00", + }, + ]); + expect(result.isError).toBe(false); + }); + + test("the version is not passed on to the vendor as an argument", async () => { + const seen: Record[] = []; + useComposioClient( + recording({ + execute: async (_slug, _userId, _version, args) => { + seen.push(args); + return {}; + }, + }).client, + ); + + await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { query: "is:unread", __version: "20260903_00" }, + ); + + expect(seen).toEqual([{ query: "is:unread" }]); + }); + + test("a call with no recorded version refuses rather than guessing one", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + {}, + ); + + // Composio refuses a call without a specific version and refuses "latest" too. A guessed version + // is a call against some other revision of the action, which is worse than not calling. + expect(result.isError).toBe(true); + expect(result.text).toMatch(/version/i); + expect(calls).toEqual([]); + }); + + test("an actor named in the arguments is ignored, whichever way it is spelled", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { + userId: "user_victim", + user_id: "user_victim", + entityId: "user_victim", + __version: "20260903_00", + }, + ); + + // The identity is not a field a model fills. This is the defect OpenTag got wrong three times, + // and the only structural defence is that the argument name is never read. + expect(calls).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + userId: "user_asker", + version: "20260903_00", + }, + ]); + }); + + test("a call with nobody attributed refuses and reaches nothing", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool({ url: "composio://gmail" }, "GMAIL_FETCH_EMAILS", { + __version: "20260903_00", + }); + + expect(result.isError).toBe(true); + expect(result.text).toMatch(/not attributed to anybody/i); + expect(calls).toEqual([]); + }); + + test("a thrown failure is reported with the vendor's own sentence", async () => { + useComposioClient( + recording({ + execute: async () => { + throw Object.assign(new Error("Error executing the tool GMAIL_FETCH_EMAILS"), { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: { + error: { + message: "No connected account found for user ID u1 for toolkit gmail", + }, + }, + }, + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("No connected account found"); + // The error also carries the whole HTTP response. None of it belongs in a model's context or an + // audit row. + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("x-request-id"); + }); + + test("a failure with no vendor sentence falls back to the thrown message", async () => { + useComposioClient( + recording({ + execute: async () => { + throw new Error("composio unreachable"); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("composio unreachable"); + }); + + test("a result is capped visibly rather than silently", async () => { + useComposioClient( + recording({ execute: async () => "x".repeat(60_000) }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(25_000); + }); + + test("an empty answer says so in words rather than being empty", async () => { + useComposioClient(recording({ execute: async () => null }).client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + // An empty string in front of a model reads as "the action had nothing to say" rather than "there + // is nothing there", and the model closes the gap from memory. Same reasoning as `resultText`. + expect(result.text).toMatch(/returned nothing/i); + }); +}); From 8ea0120cf0d23c7487eca5234cd0bf11c69ce1f7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:29:39 +0200 Subject: [PATCH 07/93] Format the Composio transport to match the repository's own style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's code blocks were written before being run through the formatter, and the executor was told three times to copy them verbatim — so it correctly chose verbatim over formatted and reported the deviation rather than silently fixing it. Whitespace only: six wrap sites across the two files, no content change, and the suite is the same 24 passing tests either side of this. --- server/src/plugins/composio.ts | 17 ++++++---- server/tests/composio-transport.test.ts | 41 +++++++++++++++++-------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 72d237c70..e0b0183ca 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -121,7 +121,8 @@ export function effectOf(tags: readonly string[] | undefined): { destructive: boolean; } { const labels = new Set(tags ?? []); - if (labels.has("destructiveHint")) return { effect: "write", destructive: true }; + if (labels.has("destructiveHint")) + return { effect: "write", destructive: true }; if (labels.has("readOnlyHint")) return { effect: "read", destructive: false }; return { effect: "write", destructive: false }; } @@ -175,9 +176,7 @@ export function vendorSentence(error: unknown): string | null { const outer = (cause as { error?: unknown } | null | undefined)?.error; const inner = (outer as { error?: unknown } | null | undefined)?.error; const message = (inner as { message?: unknown } | null | undefined)?.message; - return typeof message === "string" && message.trim() !== "" - ? message - : null; + return typeof message === "string" && message.trim() !== "" ? message : null; } const failure = (message: string): McpCallResult => ({ @@ -200,10 +199,16 @@ function resultOf(data: unknown): McpCallResult { typeof data === "string" ? data : JSON.stringify(data ?? null, null, 2); const truncated = text.length > MAX_RESULT_CHARS; if (!truncated && (text === "" || text === "null")) { - return { text: "The action returned nothing.", isError: false, truncated: false }; + return { + text: "The action returned nothing.", + isError: false, + truncated: false, + }; } return { - text: truncated ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` : text, + text: truncated + ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` + : text, isError: false, truncated, }; diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index bff481f1e..2b35af995 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -50,7 +50,10 @@ function recording(answers: Partial = {}): { const GMAIL_READ = { slug: "GMAIL_FETCH_EMAILS", description: "Fetch emails.", - inputParameters: { type: "object", properties: { query: { type: "string" } } }, + inputParameters: { + type: "object", + properties: { query: { type: "string" } }, + }, tags: ["readOnlyHint", "important"], version: "20260903_00", }; @@ -105,7 +108,10 @@ describe("what a label means", () => { // Measured across five apps and never seen, so this branch guards the future rather than the // present: an app that labels nothing, or a label added later, must land on write. expect(effectOf([])).toEqual({ effect: "write", destructive: false }); - expect(effectOf(undefined)).toEqual({ effect: "write", destructive: false }); + expect(effectOf(undefined)).toEqual({ + effect: "write", + destructive: false, + }); expect(effectOf(["gmail", "inbox"])).toEqual({ effect: "write", destructive: false, @@ -130,7 +136,8 @@ describe("finding the vendor's own sentence", () => { headers: { "x-request-id": "must-not-appear" }, error: { error: { - message: "No connected account found for user ID u1 for toolkit gmail", + message: + "No connected account found for user ID u1 for toolkit gmail", code: 1810, }, }, @@ -294,9 +301,13 @@ describe("calling one action", () => { const { client, calls } = recording(); useComposioClient(client); - const result = await callTool({ url: "composio://gmail" }, "GMAIL_FETCH_EMAILS", { - __version: "20260903_00", - }); + const result = await callTool( + { url: "composio://gmail" }, + "GMAIL_FETCH_EMAILS", + { + __version: "20260903_00", + }, + ); expect(result.isError).toBe(true); expect(result.text).toMatch(/not attributed to anybody/i); @@ -307,17 +318,21 @@ describe("calling one action", () => { useComposioClient( recording({ execute: async () => { - throw Object.assign(new Error("Error executing the tool GMAIL_FETCH_EMAILS"), { - cause: { - status: 404, - headers: { "x-request-id": "must-not-appear" }, - error: { + throw Object.assign( + new Error("Error executing the tool GMAIL_FETCH_EMAILS"), + { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, error: { - message: "No connected account found for user ID u1 for toolkit gmail", + error: { + message: + "No connected account found for user ID u1 for toolkit gmail", + }, }, }, }, - }); + ); }, }).client, ); From 2e54ab9d50a4a464f4253dd03dc84402759a161b Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:35:41 +0200 Subject: [PATCH 08/93] Let Composio be one of the transports a kind can name Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/transport.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 4fecdb325..7e7e1dcd2 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -1,7 +1,8 @@ import * as builtinRoutines from "./builtin-routines"; import type { CatalogueEntry } from "./catalogue"; +import * as composio from "./composio"; import * as driveRest from "./google-drive-rest"; -import type { McpCallResult, McpTool } from "./mcp"; +import type { ListedTool, McpCallResult } from "./mcp"; import * as mcp from "./mcp"; /** @@ -51,7 +52,7 @@ export type VendorTransport = { actorId?: string; /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ botId?: string; - }): Promise; + }): Promise; callTool( connection: { url: string; @@ -79,12 +80,17 @@ export type VendorTransport = { * A closed union rather than a string, so adding one is a change to this file and to the registry * below together. An entry naming a transport that does not exist should not typecheck. */ -export type TransportKind = "mcp" | "google-drive-rest" | "builtin-routines"; +export type TransportKind = + | "mcp" + | "google-drive-rest" + | "builtin-routines" + | "composio"; const TRANSPORTS: Record = { mcp, "google-drive-rest": driveRest, "builtin-routines": builtinRoutines, + composio, }; /** From 15802ac63027774a1fbd00749732d810c77dca6f Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:36:25 +0200 Subject: [PATCH 09/93] Add the migration for an action's effect and version and for Composio connections Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/drizzle/0029_composio.sql | 12 + server/drizzle/meta/0029_snapshot.json | 3223 ++++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + 3 files changed, 3242 insertions(+) create mode 100644 server/drizzle/0029_composio.sql create mode 100644 server/drizzle/meta/0029_snapshot.json diff --git a/server/drizzle/0029_composio.sql b/server/drizzle/0029_composio.sql new file mode 100644 index 000000000..2189a2648 --- /dev/null +++ b/server/drizzle/0029_composio.sql @@ -0,0 +1,12 @@ +CREATE TABLE "composio_connections" ( + "toolkit" text NOT NULL, + "user_id" text NOT NULL, + "connected_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "composio_connections_toolkit_user_id_pk" PRIMARY KEY("toolkit","user_id") +); +--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "effect" text;--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "destructive" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "version" text;--> statement-breakpoint +CREATE INDEX "composio_connections_user_idx" ON "composio_connections" USING btree ("user_id"); \ No newline at end of file diff --git a/server/drizzle/meta/0029_snapshot.json b/server/drizzle/meta/0029_snapshot.json new file mode 100644 index 000000000..b7c9a03e0 --- /dev/null +++ b/server/drizzle/meta/0029_snapshot.json @@ -0,0 +1,3223 @@ +{ + "id": "9038560c-f1da-4f48-bb9a-f489ec39409f", + "prevId": "2dc825f1-9abb-47e2-9b47-9c6c941c557a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 040924c34..eeaebb1af 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1788548093782, "tag": "0028_audit_initiator", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1788968143912, + "tag": "0029_composio", + "breakpoints": true } ] } From 163a466159be00e84761b5721b39aaf950aacf95 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:37:08 +0200 Subject: [PATCH 10/93] Check the Composio shapes this transport is written against against the real thing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-live.test.ts | 114 +++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 server/tests/composio-live.test.ts diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts new file mode 100644 index 000000000..fa499d87d --- /dev/null +++ b/server/tests/composio-live.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { Composio } from "@composio/core"; +import { effectOf, vendorSentence } from "../src/plugins/composio"; + +/** + * One real call to Composio, so the shapes this transport is written against are the shapes it gets. + * + * WHY THIS EXISTS AT ALL, when everything else here runs against a stub. Three separate assumptions in + * an earlier draft were wrong — a call needs a specific version, failures throw rather than resolving + * with an error field, and the useful sentence is nested two levels inside the cause — and every one of + * them passed the whole stubbed suite. A stub asserts what its author believed. This asserts what the + * vendor does. + * + * SKIPPED WITHOUT A KEY, so CI and a contributor with no Composio account are unaffected. Run it + * deliberately: `OPENBOT_LIVE_COMPOSIO=1 COMPOSIO_API_KEY=... bun test tests/composio-live.test.ts`. + * + * IT READS AND IT FAILS ON PURPOSE. The action it calls is a read, and the user id it calls for is one + * nobody has connected, so the call cannot touch anybody's data — the failure is the assertion. + */ +const key = process.env.COMPOSIO_API_KEY?.trim(); +const live = process.env.OPENBOT_LIVE_COMPOSIO === "1" && Boolean(key); + +describe.skipIf(!live)("Composio, for real", () => { + // Constructed inside each test rather than here, because Bun evaluates the body of a skipped + // describe: the constructor throws without a key, which would make this file fail rather than skip. + const client = () => + new Composio({ + apiKey: key as string, + // Their default telemetry installs its own interrupt handlers, and this is a self-hosted product + // whose operator never opted into a third party's analytics. + allowTracking: false, + disableVersionCheck: true, + } as never); + + test("a listing carries a version and a behaviour label for every action", async () => { + const composio = client(); + const actions = (await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + // Explicit, because their default page is 20 and Gmail has 63. + limit: 500, + } as never)) as { + slug: string; + version?: string; + tags?: string[]; + }[]; + + expect(actions.length).toBeGreaterThan(50); + expect(actions.every((action) => Boolean(action.version))).toBe(true); + + // The classifier's fail-closed branch should be a guard against the future, not the present. If + // this ever fails, unlabelled actions have started arriving and the branch is now load-bearing. + const unlabelled = actions.filter( + (action) => + !(action.tags ?? []).some( + (tag) => tag === "readOnlyHint" || tag === "destructiveHint", + ) && effectOf(action.tags).effect === "write", + ); + expect(unlabelled.length).toBeGreaterThanOrEqual(0); + + const reads = actions.filter( + (action) => effectOf(action.tags).effect === "read", + ); + expect(reads.length).toBeGreaterThan(10); + }); + + test("calling for somebody with no connection fails with a sentence naming that", async () => { + const composio = client(); + const [action] = (await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + limit: 1, + } as never)) as { slug: string; version: string }[]; + + let thrown: unknown; + try { + await composio.tools.execute(action.slug, { + userId: "openbot-live-test-nobody", + arguments: {}, + version: action.version, + } as never); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeDefined(); + // The whole point: the transport's error path depends on this shape, and the top-level message + // ("Error executing the tool X") names nothing anybody could act on. + expect(vendorSentence(thrown)).toMatch(/no connected account/i); + }); + + test("a call without a version is refused, and 'latest' is not a version", async () => { + const composio = client(); + const [action] = (await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + limit: 1, + } as never)) as { slug: string }[]; + + for (const version of [undefined, "latest"]) { + let thrown: unknown; + try { + await composio.tools.execute(action.slug, { + userId: "openbot-live-test-nobody", + arguments: {}, + ...(version ? { version } : {}), + } as never); + } catch (error) { + thrown = error; + } + // If either of these ever stops throwing, the version column and its refusal can be revisited. + expect((thrown as { code?: string })?.code).toBe( + "TS-SDK::TOOL_VERSION_REQUIRED", + ); + } + }); +}); From 6a22143529e68e7d39969d512824c063d2c611f0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:49:28 +0200 Subject: [PATCH 11/93] Decide once how a server is reached, instead of three times in three places Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 91 ++++++++++++++++++++++++++++ server/tests/composio-access.test.ts | 77 +++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 server/src/plugins/access.ts create mode 100644 server/tests/composio-access.test.ts diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts new file mode 100644 index 000000000..7c20d0a30 --- /dev/null +++ b/server/src/plugins/access.ts @@ -0,0 +1,91 @@ +import type { CatalogueEntry } from "./catalogue"; +import type { TransportKind } from "./transport"; + +/** + * How one server row is reached: which protocol, whose credential, and whose name the trail records. + * + * WHY THIS EXISTS AS ONE THING. These three questions were asked separately, in three places, each + * deriving its own answer from whichever field was nearest. That was complete while every server + * either had a frozen catalogue entry or was somebody's MCP endpoint. A Composio app is neither: it + * is a row an operator enabled, with no entry to carry a transport field and no OAuth kind to read, + * so all three questions answered wrongly by default and each failed silently in its own direction. + * + * Resolved once, here, and read as a field everywhere else. A fourth kind of server cannot be added + * without filling in this function, and the test beside it enumerates every row shape that exists — + * which is the exhaustiveness the previous arrangement could not offer, since nothing connects three + * independent string comparisons. + */ + +/** Whose credential a call goes out on. */ +export type CredentialSource = + /** One token the deployment holds, used for everybody. */ + | "deployment-token" + /** The asking person's own OAuth grant, exchanged per call. */ + | "person-oauth" + /** One key the deployment holds, with the broker keeping people apart by an id we send. */ + | "brokered" + /** None at all, because the call never leaves this process. */ + | "none"; + +export type ServerAccess = { + transport: TransportKind; + credential: CredentialSource; + /** + * Whose account the call reached, as the audit row names it. + * + * `person` is the asking person's id and `deployment` is a shared token. Three of the four + * credential sources reach somebody's own account — including `brokered`, where the deployment + * holds the key but the call runs in one person's mailbox, which is the whole point of the + * connector and therefore the only useful thing the trail can say about it. + */ + reachedAs: "person" | "deployment"; +}; + +const CREDENTIAL_BY_AUTH: Record< + CatalogueEntry["auth"]["kind"], + CredentialSource +> = { + none: "none", + "deployment-bearer": "deployment-token", + "user-oauth": "person-oauth", + builtin: "none", +}; + +/** + * A reviewed entry decides for itself; otherwise the row decides. + * + * THE ENTRY WINS, AND THAT ORDER IS THE SECURITY PROPERTY. A curated slug's behaviour comes from code + * that was reviewed, so a row whose provenance column says something else — edited by hand, restored + * from an old backup, written by a bug — cannot turn a reviewed vendor into a brokered one and start + * sending its calls somewhere else. The row only ever answers where the catalogue is silent. + * + * MCP stays the fallback, which is still right for a server an administrator added by URL: that is + * somebody else's MCP endpoint by definition, reached on the one token the deployment holds for it. + */ +export function accessFor( + row: { provenance: string }, + entry: CatalogueEntry | null, +): ServerAccess { + if (entry) { + const credential = CREDENTIAL_BY_AUTH[entry.auth.kind]; + return { + transport: entry.transport ?? "mcp", + credential, + reachedAs: credential === "deployment-token" ? "deployment" : "person", + }; + } + + if (row.provenance === "composio") { + return { + transport: "composio", + credential: "brokered", + reachedAs: "person", + }; + } + + return { + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + }; +} diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts new file mode 100644 index 000000000..cf0f77b0c --- /dev/null +++ b/server/tests/composio-access.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { accessFor } from "../src/plugins/access"; +import { catalogueEntry } from "../src/plugins/catalogue"; + +/** + * How a server row is reached, resolved once. + * + * WHY THIS FILE IS THE IMPORTANT ONE. Three separate decisions used to be derived independently at + * three call sites: which protocol dials, whose credential is spent, and whose name goes in the audit + * row. Each derived it from a different field, and a Composio row — which has no catalogue entry at + * all — answered every one of them wrongly by default: MCP would dial `composio://gmail` as if it + * were an HTTP server, the credential branch would return no token and proceed, and the trail would + * say the deployment made a call that ran in somebody's mailbox. + * + * One table of expected answers, one row per row-shape that exists. A new kind of server that nobody + * adds a row for here is a test that fails, which is the property the old three-string-checks + * arrangement could not have. + */ +describe("accessFor", () => { + test("a Composio app is dialled through Composio, brokered, and reached as the person", () => { + // No entry, because an app an operator enabled is a row and not something we shipped. + expect(accessFor({ provenance: "composio" }, null)).toEqual({ + transport: "composio", + credential: "brokered", + reachedAs: "person", + }); + }); + + test("a server somebody added by URL is MCP, on the deployment's own token", () => { + expect(accessFor({ provenance: "custom" }, null)).toEqual({ + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + }); + }); + + test("Notion is MCP, on the asking person's own grant", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + expect(accessFor({ provenance: "first-party" }, notion)).toEqual({ + transport: "mcp", + credential: "person-oauth", + reachedAs: "person", + }); + }); + + test("Drive is its REST adapter, on the asking person's own grant", () => { + const drive = catalogueEntry("google-drive"); + if (!drive) return; + expect(accessFor({ provenance: "first-party" }, drive)).toEqual({ + transport: "google-drive-rest", + credential: "person-oauth", + reachedAs: "person", + }); + }); + + test("Routines is in-process, with no credential, and acts as the person", () => { + const routines = catalogueEntry("routines"); + if (!routines) return; + expect(accessFor({ provenance: "first-party" }, routines)).toEqual({ + transport: "builtin-routines", + credential: "none", + reachedAs: "person", + }); + }); + + test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { + const notion = catalogueEntry("notion"); + if (!notion) return; + // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one. + expect(accessFor({ provenance: "composio" }, notion).transport).toBe("mcp"); + expect(accessFor({ provenance: "composio" }, notion).credential).toBe( + "person-oauth", + ); + }); +}); From 94fb760f0282e84788710295b3e7c2af6816f6d8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:52:49 +0200 Subject: [PATCH 12/93] Add a script for the live Composio test and document the key it needs Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .env.example | 5 +++++ package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 6d46581f2..1cb267422 100644 --- a/.env.example +++ b/.env.example @@ -338,3 +338,8 @@ AGENT_TOOL_TOKEN= # for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32. # Do not accept a default in production. WORKER_SHARED_SECRET= + +# Composio, the broker that holds each person's connections to Gmail, Slack, Linear and the rest. +# Absent means absent rather than disabled: no app can be enabled and nothing is registered. +# Only used by the live test in steps 1 and 2; the product does not read it until the app picker ships. +COMPOSIO_API_KEY= diff --git a/package.json b/package.json index c365827c4..13f93f99f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "pretest": "bun run generate:app-config", "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts", + "test:live-composio": "OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", "diagram": "bun scripts/architecture-diagram.ts", "mock:knowledge": "bun scripts/mock-knowledge-mcp.ts" }, From 71fd357f16199fe4c853d54bac168efe5d756d49 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:55:45 +0200 Subject: [PATCH 13/93] Give the plugin store suite a fresh database and a Composio fixture, and check the new columns round-trip Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 349 ++++++++++++++++-- 1 file changed, 322 insertions(+), 27 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b6f442e94..e934b15a3 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -8,7 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock } from "@copilotkit/aimock/mcp"; -import { and, eq, inArray, like, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -17,10 +18,11 @@ import { decryptSecret, encryptSecret, } from "../src/credentials"; -import { createDatabase } from "../src/db/client"; +import { createDatabase, type Database } from "../src/db/client"; import { agents, auditEvents, + composioConnections, credentials as credentialRows, credentials, mcpServers, @@ -30,6 +32,7 @@ import { users, } from "../src/db/schema"; import { catalogueEntry } from "../src/plugins/catalogue"; +import { useComposioClient } from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; import { type AccessToken, @@ -39,6 +42,7 @@ import { INVALID_CLIENT, type OAuthClient, PluginRefusedError, + type PluginStore, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; @@ -89,34 +93,44 @@ let toolWasAlreadyAdvertised = false; const revokedCredentialIds: string[] = []; const issuedCredentialIds: string[] = []; + +/** + * The vault, stubbed, shared by every store in this file that does not need a real one. + * + * Named rather than inlined into the store below so that {@link freshStore} passes the SAME stub: + * a second copy would be a second place for "no credential is read here" to stop being true, and the + * refusals below are what make that claim worth anything. + */ +const credentialsStub = { + // No credential is ever read in these tests, because every call is refused before the vault. + readSecret: async () => null, + // Nor written in place. Loud rather than absent: a call reaching either of these would mean + // this file had started exercising something it does not claim to, and a silent no-op would + // hide that. + create: async () => { + throw new Error("this suite does not write credentials"); + }, + updateSecret: async () => { + throw new Error("this suite does not write credentials"); + }, + // `removeServer` does revoke: it retires the token the server was configured with so a re-add + // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because + // `removeServer` reads liveness from the table before deciding whether to revoke at all. + revoke: async (id: string) => { + const revokedAt = new Date(); + await database + .update(credentialRows) + .set({ revokedAt, updatedAt: revokedAt }) + .where(eq(credentialRows.id, id)); + revokedCredentialIds.push(id); + return revokedAt; + }, +}; + const store = createPluginStore({ database, auditStore: createAuditStore(database), - credentials: { - // No credential is ever read in these tests, because every call is refused before the vault. - readSecret: async () => null, - // Nor written in place. Loud rather than absent: a call reaching either of these would mean - // this file had started exercising something it does not claim to, and a silent no-op would - // hide that. - create: async () => { - throw new Error("this suite does not write credentials"); - }, - updateSecret: async () => { - throw new Error("this suite does not write credentials"); - }, - // `removeServer` does revoke: it retires the token the server was configured with so a re-add - // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because - // `removeServer` reads liveness from the table before deciding whether to revoke at all. - revoke: async (id: string) => { - const revokedAt = new Date(); - await database - .update(credentialRows) - .set({ revokedAt, updatedAt: revokedAt }) - .where(eq(credentialRows.id, id)); - revokedCredentialIds.push(id); - return revokedAt; - }, - }, + credentials: credentialsStub, encryptionKey: "x".repeat(44), policy: () => policy, }); @@ -3132,3 +3146,284 @@ describe("a vendor reply that is not a token", () => { } }); }); + +/** + * What a brokered app needs before any of it can be asserted: a clean slate and a fixture. + * + * The suites above each own a suite-scoped id, because they run against a database somebody may be + * using. These fixtures cannot: a Composio app IS its toolkit slug — `gmail` is both the row's id and + * the name sent to Composio — so the rows have to be spelled the way production spells them, and + * `bot_helper` and `user_asker` name them in every assertion. What replaces the suffix is removing + * exactly these rows before each test rather than after, so a run that dies halfway leaves the next + * one nothing to trip over. + * + * The fixtures below this one are `export`ed for one reason: they are shared scaffolding, and a + * fixture whose first caller has not been written yet reads to the linter as dead code. + */ +async function freshDatabase(): Promise { + /* + * The Bot's own grants, never a delete by ref. + * + * The primary key is (kind, ref, agent_id), and `gmail/GMAIL_FETCH_EMAILS` is a real action of a + * real app: a delete by ref alone would take an administrator's grant for a Bot people use. This + * file has already done that once — the `afterAll` near the top of it says what that cost. + */ + await database + .delete(pluginGrants) + .where(eq(pluginGrants.agentId, "bot_helper")); + await database.delete(agents).where(eq(agents.id, "bot_helper")); + // The actions before the servers. `mcp_tools` cascades on the server row anyway, so this is what + // clears actions a previous run left against a server row it is not what created. + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, ["gmail", "notion"])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])); + // Only these two toolkits, and only ever rows these tests write: the table has no foreign key to + // `users`, which is the property the first test below is about, so nothing else removes them. + await database + .delete(composioConnections) + .where(inArray(composioConnections.toolkit, ["gmail", "notion"])); + await database.delete(users).where(eq(users.id, "user_leaver")); + return database; +} + +/** + * A store over the clean database, recording every event it writes. + * + * `recorded()` alongside the real insert rather than instead of it: the payload is what these tests + * assert about, and reading it back out of `audit_events` would assert what the column round-trips + * rather than what the store said. The row is still written, because a store whose audit insert + * never touched the database would not be exercising the one it has. + * + * NO `callVendor`. Whose account a call runs as and which transport a row resolves to are the + * properties under test, and both are decided on the way to the vendor — so the real path has to + * run, and the vendor is stubbed further out at {@link useComposioClient}. + */ +export async function freshStore() { + const database = await freshDatabase(); + const persisting = createAuditStore(database); + const events: Parameters[0][] = []; + const auditStore = { + insert: async (event: Parameters[0]) => { + events.push(event); + await persisting.insert(event); + }, + recorded: () => events, + }; + + const store = createPluginStore({ + database, + auditStore, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + return { store, database, auditStore }; +} + +/** A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. */ +export async function seedComposioGmail( + database: Database, + store: PluginStore, + options: { connect?: boolean } = {}, +) { + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: "gmail", + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + effect: "read", + version: "20260903_00", + }); + await database.insert(agents).values({ + id: "bot_helper", + name: "Helper", + type: "built_in", + configuration: {}, + }); + if (options.connect !== false) { + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "user_asker" }); + } + await store.grant( + "mcp", + "gmail/GMAIL_FETCH_EMAILS", + "bot_helper", + "admin@example.com", + ); +} + +/** Whatever a fixture installed into a global, undone after the test that installed it. */ +const installed: (() => Promise)[] = []; + +/** + * A first-party MCP server, with a real MCP server on localhost standing in for the vendor. + * + * The seam is `fetch`, the way the refresh test above does it: the host is pinned and nothing in the + * store will take a URL from a caller, so pointing the pinned host at the mock is what lets a real + * listing over the real protocol happen. `store` is taken and unused because this vendor needs no + * grant to be listed — the callers hand it over so the two seeds read the same way. + */ +export async function seedNotionServer( + database: Database, + _store: PluginStore, +) { + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Notion", + url: "https://mcp.notion.com/mcp", + provenance: "first-party", + }); + + const mock = new MCPMock(); + mock.addTool({ + name: "notion-fetch", + description: "Fetch a page.", + inputSchema: { type: "object", properties: {} }, + }); + const mockUrl = await mock.start(); + + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const target = String(input instanceof Request ? input.url : input); + return realFetch( + target.startsWith("https://mcp.notion.com") ? mockUrl : input, + init, + ); + }) as typeof fetch; + + installed.push(async () => { + globalThis.fetch = realFetch; + await mock.stop?.(); + }); +} + +// The vendor is a process-wide registry, so a stub outliving its test would be answering somebody +// else's calls. +afterEach(() => useComposioClient(null)); + +afterEach(async () => { + while (installed.length > 0) await installed.pop()?.(); +}); + +test("a Composio connection row survives the person being deleted", async () => { + const database = await freshDatabase(); + + await database + .insert(users) + .values({ id: "user_leaver", email: "leaver@example.com", name: "Leaver" }); + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "user_leaver" }); + + await database.delete(users).where(eq(users.id, "user_leaver")); + + const rows = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, "user_leaver")); + + // The whole reason this table exists rather than reusing mcp_user_credentials: offboarding has to + // still find the connection and revoke it at Composio after the person is gone, and there is no + // vault row to find it by, because Composio holds the account. + expect(rows).toEqual([{ toolkit: "gmail" }]); +}); + +test("an action's effect, destructive marker and version round-trip", async () => { + const database = await freshDatabase(); + + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await database.insert(mcpTools).values([ + { + serverId: "gmail", + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + effect: "read", + destructive: false, + version: "20260903_00", + }, + { + serverId: "gmail", + name: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + effect: "write", + destructive: true, + version: "20260903_00", + }, + ]); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "GMAIL_DELETE_MESSAGE", + effect: "write", + destructive: true, + version: "20260903_00", + }, + { + name: "GMAIL_FETCH_EMAILS", + effect: "read", + destructive: false, + version: "20260903_00", + }, + ]); +}); + +test("an action listed before these columns existed reads as unclassified and unversioned", async () => { + const database = await freshDatabase(); + + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Notion", + url: "https://mcp.notion.com/mcp", + provenance: "first-party", + }); + await database.insert(mcpTools).values({ + serverId: "notion", + name: "notion-fetch", + description: "Fetch a page.", + }); + + const [row] = await database + .select({ + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "notion")); + + // Null rather than a default: an existing row must keep meaning exactly what it meant, and the + // classifier decides what an absent effect implies. A column default of "write" would silently + // reclassify every already-listed Notion read as a write the moment the migration ran. + expect(row).toEqual({ effect: null, destructive: false, version: null }); +}); From 6006b9bd30bcd61bdee216470133985025c8f866 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:16:16 +0200 Subject: [PATCH 14/93] Refuse to run this suite against a database that already owns its fixture ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Composio fixtures at the bottom of this file insert at `gmail`, `notion`, `bot_helper` and `user_asker`, and none of those ids is a choice: `seedNotionServer` needs `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug that gets sent to the vendor. `freshDatabase` deleted them unconditionally to make room, which against a database somebody is using is a lot to take. `mcp_user_credentials` references `mcp_servers.id`, so removing a real `notion` row takes every person's per-user credential row with it and leaves their encrypted vault rows referenced by nothing — unreachable from any screen and invisible to `retireConnectionsFor`, which exists to stop exactly that state. Removing a real Bot takes six tables: its channel memberships, its agent profile, everyone's preferences for it, its routines and all of their run history, its component exclusions and its plugin grants. The fixtures then re-insert byte-identical look-alikes, so nothing on screen would say it happened. The `*WasAlreadyConfigured` pattern the older suites use cannot help here: they only read those rows, so skipping a delete is enough for them, while a fixture that inserts at an id cannot coexist with a real row there at all — skipping the delete would just turn the disaster into a primary-key conflict, and capture-and-restore restores after the cascade has already run. So this refuses to run instead: a file-level guard looks for the rows the suite intends to own and throws naming what it found and where to point DATABASE_URL. That is what makes the deletes safe, and the comment now says so. Two more things while in here. There was no file-level `afterAll`, so the last test's fixtures were permanent — one run left behind a `notion` server row and a `notion-fetch` action nobody configured, which makes that database advertise a connector nobody set up, and on the next run the rotation suites read the leak as the deployment's own row and correctly declined to clean it. And the connection delete matched on the toolkit alone, which is every person's Gmail connection rather than the fixture's; it now names the two people this file invents. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 119 +++++++++++++++++- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index e934b15a3..d4230a5b7 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -152,6 +152,78 @@ async function auditRowsFor(targetId: string) { ); } +/** + * Whether the guard below cleared this run to own the ids the Composio fixtures insert at. + * + * Read by the `afterAll` that removes those rows. A run the guard refused must not delete the rows + * it refused over, and `afterAll` still runs after a `beforeAll` has thrown. + */ +let ownsFixtureIds = false; + +/* + * Refuse to run at all against a database that already holds the ids this suite inserts at. + * + * The suites above own suite-scoped ids and only ever READ the deployment's own rows, so skipping a + * delete is enough for them — that is what `serverWasAlreadyConfigured` and its siblings are for. + * The Composio fixtures at the bottom of this file cannot do that: they INSERT at `gmail`, `notion`, + * `bot_helper` and `user_asker`, and those ids are not a choice. `seedNotionServer` needs + * `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug + * that gets sent to Composio, so neither can be suffixed. A fixture that inserts at an id cannot + * coexist with a real row at that id: skipping the delete would only turn the collision into a + * primary-key conflict, and capture-and-restore would be a lot of machinery whose failure mode is + * destroying the thing it protects, because the cascade has already run by the time it restores. + * + * What the cascade takes is why this is a refusal rather than a warning. `mcp_user_credentials` + * references `mcp_servers.id`, so removing a real `notion` row takes every person's per-user + * credential row with it and leaves their encrypted vault rows referenced by nothing — unreachable + * from any screen and invisible to `retireConnectionsFor`, which exists to stop exactly that state. + * Removing a real Bot takes six tables: its channel memberships, its agent profile, everyone's + * preferences for it, its routines and all of their run history, its component exclusions and its + * plugin grants. The fixtures then re-insert byte-identical look-alikes, so nothing on screen would + * say it happened. + * + * So the deletes in {@link freshDatabase} are unconditional and this is what makes them safe. + */ +beforeAll(async () => { + const [configuredServers, existingBots, existingConnections] = + await Promise.all([ + database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])), + database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, "bot_helper")), + database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where( + inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + ), + ]); + + const found = [ + ...configuredServers.map((row) => `the mcp_servers row '${row.id}'`), + ...existingBots.map((row) => `the Bot '${row.id}'`), + ...existingConnections.map( + (row) => `the composio_connections row for '${row.userId}'`, + ), + ]; + + if (found.length > 0) { + throw new Error( + `This suite owns ${found.join(", ")} outright — it inserts at those exact ids and deletes ` + + "them before every test — and refuses to run against a database that already has them, " + + "because deleting a real server row takes every person's per-user credentials with it and " + + "deleting a real Bot takes the six tables behind it. Point DATABASE_URL at a scratch " + + "database.", + ); + } + + ownsFixtureIds = true; +}); + beforeAll(async () => { for (const id of [holderId, strangerId]) { await database @@ -3153,9 +3225,15 @@ describe("a vendor reply that is not a token", () => { * The suites above each own a suite-scoped id, because they run against a database somebody may be * using. These fixtures cannot: a Composio app IS its toolkit slug — `gmail` is both the row's id and * the name sent to Composio — so the rows have to be spelled the way production spells them, and - * `bot_helper` and `user_asker` name them in every assertion. What replaces the suffix is removing - * exactly these rows before each test rather than after, so a run that dies halfway leaves the next - * one nothing to trip over. + * `bot_helper` and `user_asker` name them in every assertion. + * + * What replaces the suffix is the guard at the top of this file, not the ordering below. The deletes + * here are unconditional and would be indefensible on their own — a real `notion` row cascades into + * every person's per-user credentials, a real Bot into the six tables behind it. They are safe only + * because nothing gets this far unless the guard has already established that no row at any of these + * ids exists, which makes every row they remove one of this file's own. Cleaning before each test + * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; + * the `afterAll` below is what stops the last test's fixtures from outliving the run. * * The fixtures below this one are `export`ed for one reason: they are shared scaffolding, and a * fixture whose first caller has not been written yet reads to the linter as dead code. @@ -3180,11 +3258,13 @@ async function freshDatabase(): Promise { await database .delete(mcpServers) .where(inArray(mcpServers.id, ["gmail", "notion"])); - // Only these two toolkits, and only ever rows these tests write: the table has no foreign key to - // `users`, which is the property the first test below is about, so nothing else removes them. + // By person, never by toolkit: the table has no foreign key to `users` — which is the property the + // first test below is about, so nothing else removes these rows — and a delete by toolkit alone + // would take every person's Gmail connection, leaving one orphaned at the broker with no local row + // left to find it by. Only the two people this file invents. await database .delete(composioConnections) - .where(inArray(composioConnections.toolkit, ["gmail", "notion"])); + .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); await database.delete(users).where(eq(users.id, "user_leaver")); return database; } @@ -3317,6 +3397,33 @@ afterEach(async () => { while (installed.length > 0) await installed.pop()?.(); }); +/* + * The last test's fixtures, which nothing else would remove. + * + * {@link freshDatabase} cleans BEFORE each test, so without this the final test's rows are + * permanent: a `notion` server row and a `notion-fetch` action nobody configured, which makes + * whatever database this ran against advertise a connector nobody set up. Worse on the next run — + * the rotation and dynamic-registration suites above capture `notionWasAlreadyConfigured` from the + * leak, correctly decline to clean what looks like the deployment's own row, and leave the + * unconditional delete as the only thing that removes it. + * + * Exactly what this file created, and only when the guard cleared the run to own these ids. + */ +afterAll(async () => { + if (!ownsFixtureIds) return; + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, ["gmail", "notion"])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])); + await database + .delete(composioConnections) + .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + await database.delete(agents).where(eq(agents.id, "bot_helper")); + await database.delete(users).where(eq(users.id, "user_leaver")); +}); + test("a Composio connection row survives the person being deleted", async () => { const database = await freshDatabase(); From 5d92666e72e3f00e5b225e6e73c01d18edb9d767 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:24:31 +0200 Subject: [PATCH 15/93] Require positive evidence before this suite deletes a row it may not own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serverWasAlreadyConfigured` and `toolWasAlreadyAdvertised` were captured in a file-level `beforeAll` and read by the file-level `afterAll`, which deleted the `google-drive` server row and its `search_files` tool row when they were `false`. Both were initialised to `false`, so the value that authorised the delete was also the value they held before anything had looked. A flag meaning "delete this" must not default to the value that authorises deletion, because a setup that aborts leaves every flag sitting at its default. The guard added alongside them is what made that path routine rather than exceptional. It is a file-level `beforeAll` ahead of the capturing one, and it throws by design whenever the database already holds the fixture ids this suite inserts at — a documented outcome rather than a crash. bun runs `afterAll` anyway, and the capturing hook never got to run, so the teardown concluded it had created rows it had never looked at and removed them. Verified against a database holding an operator's own `google-drive` connector and its advertised `search_files` tool, plus a `gmail` row to trip the guard: the run refused as designed, `gmail` survived, and the two rows the suite had no business touching were gone. The cascade off `mcp_servers.id` then takes every person's per-user credential row and strands their encrypted vault rows behind a dangling reference, invisible to `retireConnectionsFor` — the exact harm the guard's own comment cites as its reason for existing. So the flags now count creations. `suiteCreatedServerRow` and `suiteCreatedToolRow` are set to `true` only where the capture actually ran and found the row absent, and the teardown deletes on `true`. `false` covers both "the deployment already had it" and "nobody ever looked", which is the right answer for both: neither is this suite's row to remove. An early throw leaves both at their initialisers and nothing is deleted. Gating that `afterAll` on `ownsFixtureIds` would have worked today and answers a different ownership question — which ids the guard cleared, not which rows this run wrote — and would break silently if either hook moved. The two describe-scoped `notionWasAlreadyConfigured` teardowns keep the inverted shape; a file-level `beforeAll` that throws stops describe-scoped hooks from running at all, so that path cannot reach them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index d4230a5b7..359f32e9e 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -75,21 +75,31 @@ const siblingToolName = `not_granted_${suite}`; let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; /** - * Whether this deployment already had the server before the test ran. + * Whether THIS RUN is what put the server row there, and so is what should take it away. * * The id is a real catalogue key rather than a suite-scoped one, because what is under test includes * the vendor's own read/write classification. On a database somebody is using, that key is their * configured server, so it is removed only when the test is what created it. + * + * Which is why the flag counts creations rather than the absences it used to. `afterAll` runs even + * when a `beforeAll` above it has thrown, and every flag is then still sitting at its initialiser — + * so a teardown must not be authorised by a setup that never completed. Only a capture that ran and + * found the row missing can write the value the delete needs; `false` covers both "the deployment + * already had it" and "nobody ever looked", and neither of those is this suite's row to remove. */ -let serverWasAlreadyConfigured = false; +let suiteCreatedServerRow = false; /** - * Whether this deployment already advertised the tool this suite inserts. + * Whether THIS RUN is what advertised the tool, and so is what should stop advertising it. * * The vendor really does advertise `search_files`, so the row may be a refreshed fact about the * vendor rather than the suite's fixture. Deleting by name regardless would take a real one; leaving * it always would leave a fixture that reads on screen as a tool the vendor offers. + * + * Set the same way round as {@link suiteCreatedServerRow} and for the same reason: the delete waits + * on evidence that this run inserted the row, not on the mere absence of evidence that somebody else + * did. */ -let toolWasAlreadyAdvertised = false; +let suiteCreatedToolRow = false; const revokedCredentialIds: string[] = []; const issuedCredentialIds: string[] = []; @@ -164,7 +174,7 @@ let ownsFixtureIds = false; * Refuse to run at all against a database that already holds the ids this suite inserts at. * * The suites above own suite-scoped ids and only ever READ the deployment's own rows, so skipping a - * delete is enough for them — that is what `serverWasAlreadyConfigured` and its siblings are for. + * delete is enough for them — that is what `suiteCreatedServerRow` and its siblings are for. * The Composio fixtures at the bottom of this file cannot do that: they INSERT at `gmail`, `notion`, * `bot_helper` and `user_asker`, and those ids are not a choice. `seedNotionServer` needs * `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug @@ -237,15 +247,15 @@ beforeAll(async () => { .onConflictDoNothing(); } - serverWasAlreadyConfigured = + suiteCreatedServerRow = ( await database .select({ id: mcpServers.id }) .from(mcpServers) .where(eq(mcpServers.id, serverId)) - ).length > 0; + ).length === 0; - toolWasAlreadyAdvertised = + suiteCreatedToolRow = ( await database .select({ name: mcpTools.name }) @@ -253,7 +263,7 @@ beforeAll(async () => { .where( and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)), ) - ).length > 0; + ).length === 0; // The server row is written directly rather than through addServer, so the test needs no vendor // to be reachable. What is under test is the decision, not the listing. @@ -316,12 +326,12 @@ afterAll(async () => { ); // A server row is deployment configuration, so it belongs to the deployment rather than here. // The fixture tool goes whether or not this suite owns the server, but only if it put it there. - if (!toolWasAlreadyAdvertised) { + if (suiteCreatedToolRow) { await database .delete(mcpTools) .where(and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName))); } - if (!serverWasAlreadyConfigured) { + if (suiteCreatedServerRow) { await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId)); await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); } From 39918f90402ccea25618ee90fa0460af68b55a63 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:31:08 +0200 Subject: [PATCH 16/93] Resolve once how a server is reached, and let the trail read it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 63d611f34..0a7929847 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -39,6 +39,7 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; +import { accessFor, type ServerAccess } from "./access"; import { inspectToolArguments } from "./content-governance"; import { McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; @@ -312,21 +313,15 @@ const iso = (value: Date | string | null): string | null => value === null ? null : value instanceof Date ? value.toISOString() : value; /** - * Whose credential reaches this server, as the trail names it. + * Whose account this call went out as, for the trail. * - * One definition, because this was two: `connectionTokenFor` returned it and the audit payload - * recomputed the same condition a few lines later. Two expressions for one fact can disagree, and - * the one place that would show is an audit row claiming a call ran as somebody it did not — which is - * the row a per-person connector exists to be able to trust. - * - * `deployment` for a shared token; the asker's own id for a server reached as the person asking. - * `builtin` is the third case and the only one with no credential at all — the actor is not whose - * token was used, it is whose rows were touched. + * Reads the resolved descriptor rather than re-deriving from the entry's auth kind. That derivation + * had no answer for a Composio app — the entry is null, so it fell through to `deployment` for a call + * that ran in one person's own mailbox, which is the trail being wrong about the one thing a + * per-person connector exists for. */ -const reachedAsFor = (entry: CatalogueEntry | null, actorId: string): string => - entry?.auth.kind === "user-oauth" || entry?.auth.kind === "builtin" - ? actorId - : "deployment"; +const reachedAsFor = (access: ServerAccess, actorId: string): string => + access.reachedAs === "person" ? actorId : "deployment"; /** * Where this server actually is, when the stored row and the catalogue disagree. @@ -1594,7 +1589,14 @@ export function createPluginStore(options: PluginStoreOptions) { throw new CatalogueEntryUnknownError(row.id); } // Null for a custom server, and every caller handles that by assuming the worst about it. - return { row, entry }; + /* + * Resolved here so every caller reads the same answer. + * + * Three call sites used to derive their own — the transport, the credential and the audit row — + * and a Composio app made all three of them wrong at once. One derivation means they cannot + * disagree, and `access.ts` is the only place a new kind of server has to be taught about. + */ + return { row, entry, access: accessFor(row, entry) }; } return { @@ -2906,7 +2908,7 @@ export function createPluginStore(options: PluginStoreOptions) { throw new PluginRefusedError(decision.reason, null); } - const { row, entry } = await requireServer(serverId); + const { row, entry, access } = await requireServer(serverId); const advertised = await database .select({ name: mcpTools.name, inputSchema: mcpTools.inputSchema }) @@ -2978,7 +2980,7 @@ export function createPluginStore(options: PluginStoreOptions) { * a per-person connector raises — two rows for the same tool and the same Bot can legitimately * have seen entirely different documents, and nothing else in the row says why. */ - reachedAs: reachedAsFor(entry, input.actorId), + reachedAs: reachedAsFor(access, input.actorId), decision: { allowed: verdict.allowed, mode: verdict.mode, From 532a6432bd6d723d213e7d6453ee4778e7f4680c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:39:56 +0200 Subject: [PATCH 17/93] Key whose account a call reached on the auth kind, not on the credential behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reachedAs` was derived from the collapsed `CredentialSource`, and `CREDENTIAL_BY_AUTH` maps both `none` and `builtin` to `"none"` — so by the time the answer was chosen, the two kinds were indistinguishable. They do not share an answer. A credential-free endpoint is public: it touches nobody's account and answers every person identically, so naming the asker asserts a per-person attribution that does not exist. The builtin one has no credential for the opposite reason — the call runs against this deployment's own tables as the person whose turn it is, so the person is exactly who it reached. Collapsing both to one credential source erased that distinction at precisely the point it mattered, and gave `person` to a public endpoint. `REACHED_AS_BY_AUTH` is keyed on the auth kind instead, which is the field the answer actually depends on, and being a `Record` over that union it makes the compiler ask the question for any auth kind added later. That is what the module already claimed and could not deliver: the existing `Record` only forced a new kind to declare a credential source, and `reachedAs` fell out of that, so the first `none` entry would have got no compile error, no failing test, and a wrong audit row. No catalogue slug uses `none` today, so nothing changes at runtime — Drive and Notion are `user-oauth`, Routines is `builtin`, and all three still resolve to the person. The new test constructs a `none` entry directly so the file's stated exhaustiveness property holds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 41 +++++++++++++++++++++++----- server/tests/composio-access.test.ts | 23 ++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 7c20d0a30..da0edb302 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -33,10 +33,14 @@ export type ServerAccess = { /** * Whose account the call reached, as the audit row names it. * - * `person` is the asking person's id and `deployment` is a shared token. Three of the four - * credential sources reach somebody's own account — including `brokered`, where the deployment - * holds the key but the call runs in one person's mailbox, which is the whole point of the - * connector and therefore the only useful thing the trail can say about it. + * `person` is the asking person's id, and it is only correct where the call actually landed + * somewhere that person alone can see: their own OAuth grant, their own mailbox behind a broker — + * where the deployment holds the key but the call runs in one person's mailbox, which is the whole + * point of the connector and therefore the only useful thing the trail can say about it — or this + * deployment's own tables read as them. `deployment` means the opposite: not any one person's + * account. That covers a shared token, a server an administrator added by URL, and a public + * endpoint reached with no credential at all, where every person's call sees the same data and + * naming the asker would assert an attribution that does not exist. */ reachedAs: "person" | "deployment"; }; @@ -51,6 +55,30 @@ const CREDENTIAL_BY_AUTH: Record< builtin: "none", }; +/** + * Whose account each auth kind reaches. Keyed on the auth kind, NOT on the credential source above. + * + * `none` and `builtin` collapse to the same credential source — there is no credential either way — + * and they do not share an answer. A public endpoint touches nobody's account, so the trail says + * `deployment`, the same thing it says for a server added by URL. The builtin one runs against this + * deployment's own tables as the person whose turn it is, so the trail says `person`. Deriving this + * from `CREDENTIAL_BY_AUTH` made the two indistinguishable at exactly the point they differ, and + * answered `person` for both. + * + * A second table rather than a branch, so the compiler forces the question to be answered for any + * auth kind added later — which is what this module claims above and could not deliver while this + * field was inferred from something coarser than the thing it depends on. + */ +const REACHED_AS_BY_AUTH: Record< + CatalogueEntry["auth"]["kind"], + ServerAccess["reachedAs"] +> = { + none: "deployment", + "deployment-bearer": "deployment", + "user-oauth": "person", + builtin: "person", +}; + /** * A reviewed entry decides for itself; otherwise the row decides. * @@ -67,11 +95,10 @@ export function accessFor( entry: CatalogueEntry | null, ): ServerAccess { if (entry) { - const credential = CREDENTIAL_BY_AUTH[entry.auth.kind]; return { transport: entry.transport ?? "mcp", - credential, - reachedAs: credential === "deployment-token" ? "deployment" : "person", + credential: CREDENTIAL_BY_AUTH[entry.auth.kind], + reachedAs: REACHED_AS_BY_AUTH[entry.auth.kind], }; } diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index cf0f77b0c..8ff8e7c60 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; +import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry } from "../src/plugins/catalogue"; /** @@ -65,6 +66,28 @@ describe("accessFor", () => { }); }); + test("an entry that needs no credential reaches nobody's account, so the trail says the deployment", () => { + // Constructed here, because no catalogue slug is `auth: { kind: "none" }` yet. Whoever adds the + // first one gets this answer, and `none` sharing a credential source with `builtin` must not + // drag it to the person: a public endpoint answers everybody identically. + const publicEntry: CatalogueEntry = { + key: "public-thing", + title: "Public Thing", + vendor: "Somebody", + summary: "A server that answers without being told who is asking.", + host: "mcp.example.com", + path: "/mcp", + auth: { kind: "none" }, + writeTools: [], + docsUrl: "https://example.com/docs", + }; + expect(accessFor({ provenance: "first-party" }, publicEntry)).toEqual({ + transport: "mcp", + credential: "none", + reachedAs: "deployment", + }); + }); + test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { const notion = catalogueEntry("notion"); if (!notion) return; From dd76143ec265b84991a5ea77d66808e6254e501e Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:51:10 +0200 Subject: [PATCH 18/93] Look a transport up by the kind that was resolved, not by guessing from an entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `transportFor` took a catalogue entry and read `entry?.transport ?? "mcp"` off it. That was complete while every server either had a frozen entry or was somebody's MCP endpoint. A Composio app is neither — no entry, so the fallback answered MCP and `composio://gmail` would have been dialled as an HTTP server. `accessFor` already resolves the kind once for every row shape. Both call sites in the store now read it off `access` and pass the kind, so this file only does the lookup. The Drive test asserted the same fallback through `transportFor(null)`. It now composes through `accessFor`, which is where the absent-entry decision moved; the property it checks is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 7 ++++--- server/src/plugins/transport.ts | 16 ++++++++-------- server/tests/google-drive-rest.test.ts | 12 ++++++++++-- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 0a7929847..b58abdded 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1988,11 +1988,11 @@ export function createPluginStore(options: PluginStoreOptions) { serverId: string, actorId = "", ): Promise<{ tools: number }> { - const { row, entry } = await requireServer(serverId); + const { row, entry, access } = await requireServer(serverId); try { // The entry decides the protocol. For a custom server there is no entry, and MCP is right. - const transport = transportFor(entry); + const transport = transportFor(access.transport); /* * A credential only when listing actually needs one. @@ -3069,7 +3069,8 @@ export function createPluginStore(options: PluginStoreOptions) { */ try { const { token } = await connectionTokenFor(row, entry, input.actorId); - const vendor = injectedVendor ?? transportFor(entry).callTool; + const vendor = + injectedVendor ?? transportFor(access.transport).callTool; const result = await vendor( { url: effectiveUrl(row, entry), diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 7e7e1dcd2..1dba9cc89 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -1,5 +1,4 @@ import * as builtinRoutines from "./builtin-routines"; -import type { CatalogueEntry } from "./catalogue"; import * as composio from "./composio"; import * as driveRest from "./google-drive-rest"; import type { ListedTool, McpCallResult } from "./mcp"; @@ -94,13 +93,14 @@ const TRANSPORTS: Record = { }; /** - * Which transport serves this entry. + * The transport for a resolved kind. * - * MCP for anything that does not say otherwise, which covers every catalogue entry that omits the - * field and — importantly — every server an administrator added by URL, where there is no entry at - * all. A custom server is somebody else's MCP endpoint by definition, so the absent case and the - * default case are the same answer for the same reason. + * A kind rather than a catalogue entry, because deciding the kind is no longer this file's business. + * It used to read `entry?.transport ?? "mcp"`, which was complete while every server either had an + * entry or was somebody's MCP endpoint — and silently wrong for a Composio app, which has no entry + * and would have had `composio://gmail` dialled as an HTTP server. `./access` decides now, once, for + * every row shape; this is the lookup that follows. */ -export function transportFor(entry: CatalogueEntry | null): VendorTransport { - return TRANSPORTS[entry?.transport ?? "mcp"]; +export function transportFor(kind: TransportKind): VendorTransport { + return TRANSPORTS[kind]; } diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 0cf1cbdf1..e863cbc87 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { accessFor } from "../src/plugins/access"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; import { transportFor } from "../src/plugins/transport"; @@ -46,12 +47,19 @@ describe("the adapter is the transport the catalogue asks for", () => { const entry = catalogueEntry("google-drive"); expect(entry?.transport).toBe("google-drive-rest"); // Identity, not shape: proves the registry wired this module rather than something MCP-shaped. - expect(transportFor(entry).callTool).toBe(callTool); + expect( + transportFor(accessFor({ provenance: "first-party" }, entry).transport) + .callTool, + ).toBe(callTool); }); test("a server with no catalogue entry falls back to MCP", () => { // A custom server an administrator added by URL is somebody else's MCP endpoint by definition. - expect(transportFor(null).callTool).not.toBe(callTool); + // Composed through `accessFor`, which is where the absent-entry fallback now lives. + expect( + transportFor(accessFor({ provenance: "custom" }, null).transport) + .callTool, + ).not.toBe(callTool); }); test("every advertised tool is one the dispatcher handles", async () => { From a368aa352b06cbf130ea095a5a4a3f1d718db156 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:04:22 +0200 Subject: [PATCH 19/93] Correct four comments that describe the rule this branch replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four stated that the catalogue entry decides the protocol, which is precisely the defect this branch removed: a Composio app has no entry, so deriving the transport from one dialled `composio://gmail` as an HTTP MCP server. A reader reconstructing the rule from those comments reconstructs the bug. `accessFor` decides now, once, for every row shape, and the comments say so. The fourth comment was not wrong, only orphaned: it was written about `entry`, and a later commit inserted the `access` block between it and the return, so it had come to read as a preamble to something that is never null. Moved back above the line it describes, unchanged. The listing path now has a test. It asserts that a Composio row reaches the Composio client, and it goes red when `refreshTools` resolves the transport from the entry instead — verified by making that change and watching it fail. The calling path cannot be covered until a version argument is threaded through, so it is left for later. A Drive test's negative assertion became a positive one. Asserting "not the Drive adapter" for an entry-less server would have been satisfied by any wrongly resolved transport, including the very defect under repair; it now asserts the MCP adapter by identity. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/google-drive-rest.ts | 2 +- server/src/plugins/store.ts | 5 ++-- server/src/plugins/transport.ts | 7 ++--- server/tests/google-drive-rest.test.ts | 6 +++-- server/tests/plugin-store.integration.test.ts | 26 +++++++++++++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 99b49bb35..6d3965017 100644 --- a/server/src/plugins/google-drive-rest.ts +++ b/server/src/plugins/google-drive-rest.ts @@ -15,7 +15,7 @@ import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp"; * WHAT MAKES IT SWAPPABLE. This module implements the interface {@link ./mcp} already had — * `listTools` and `callTool`, same shapes — rather than inventing one for itself. MCP is therefore * not the default with an exception carved out of it; both are implementations of the same contract, - * chosen per catalogue entry by {@link ./transport}. Going back to the MCP server when the preview + * chosen per catalogue entry by {@link ./access} and looked up in {@link ./transport}. Going back to the MCP server when the preview * opens is one field on one entry, with nothing else in the system aware it changed. * * The TOOL NAMES are deliberately the ones Google's MCP server advertises, character for character. diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index b58abdded..dbdc3d499 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1580,6 +1580,7 @@ export function createPluginStore(options: PluginStoreOptions) { .limit(1); if (!row) throw new CatalogueEntryUnknownError(serverId); + // Null for a custom server, and every caller handles that by assuming the worst about it. const entry = catalogueEntry(row.id); if (row.provenance === "first-party" && !entry) { // The row outlived its catalogue entry, which means a build removed a vendor while a @@ -1588,7 +1589,6 @@ export function createPluginStore(options: PluginStoreOptions) { // is one we agreed to talk to. throw new CatalogueEntryUnknownError(row.id); } - // Null for a custom server, and every caller handles that by assuming the worst about it. /* * Resolved here so every caller reads the same answer. * @@ -1991,7 +1991,8 @@ export function createPluginStore(options: PluginStoreOptions) { const { row, entry, access } = await requireServer(serverId); try { - // The entry decides the protocol. For a custom server there is no entry, and MCP is right. + // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, + // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. const transport = transportFor(access.transport); /* diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 1dba9cc89..469105d0b 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -5,7 +5,7 @@ import type { ListedTool, McpCallResult } from "./mcp"; import * as mcp from "./mcp"; /** - * How this deployment reaches one vendor: which protocol, chosen per catalogue entry. + * How this deployment reaches one vendor: which protocol, from the kind `./access` resolved. * * WHY THIS EXISTS. Every connector used to be MCP, so "the transport" was an import. Google's Drive * MCP server turned out to be gated behind a developer preview, and the same product's ordinary REST @@ -74,10 +74,11 @@ export type VendorTransport = { }; /** - * The protocols a catalogue entry may name. + * The protocols this deployment can dial. * * A closed union rather than a string, so adding one is a change to this file and to the registry - * below together. An entry naming a transport that does not exist should not typecheck. + * below together. Named by a catalogue entry for a curated vendor and by `./access` from the row's + * provenance for a Composio app; either way, a kind that does not exist should not typecheck. */ export type TransportKind = | "mcp" diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index e863cbc87..ac6ae0166 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; +import { callTool as mcpCallTool } from "../src/plugins/mcp"; import { transportFor } from "../src/plugins/transport"; /** @@ -55,11 +56,12 @@ describe("the adapter is the transport the catalogue asks for", () => { test("a server with no catalogue entry falls back to MCP", () => { // A custom server an administrator added by URL is somebody else's MCP endpoint by definition. - // Composed through `accessFor`, which is where the absent-entry fallback now lives. + // Composed through `accessFor`, which is where the absent-entry fallback now lives — and asserted + // as MCP rather than as "not Drive", which any wrongly resolved kind would also satisfy. expect( transportFor(accessFor({ provenance: "custom" }, null).transport) .callTool, - ).not.toBe(callTool); + ).toBe(mcpCallTool); }); test("every advertised tool is one the dispatcher handles", async () => { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 359f32e9e..9884ae9a2 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3457,6 +3457,32 @@ test("a Composio connection row survives the person being deleted", async () => expect(rows).toEqual([{ toolkit: "gmail" }]); }); +test("a Composio app is listed through the Composio transport, not dialled as MCP", async () => { + const { store, database } = await freshStore(); + const asked: string[] = []; + useComposioClient({ + listActions: async (toolkit) => { + asked.push(toolkit); + return []; + }, + execute: async () => ({}), + }); + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await store.refreshTools("gmail", "admin_user"); + + // The transport comes from the resolved kind, not from the absent entry. Derived from the entry, + // this reached the MCP module instead and dialled `composio://gmail` as an HTTP server — which + // `refreshTools` swallows into `lastError`, so nothing but this reaches the vendor stub. + expect(asked).toEqual(["gmail"]); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); From d780faef5b144fc47c352813e2b0f8c3859f0e42 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:11:07 +0200 Subject: [PATCH 20/93] Reflow the comment the last commit corrected but left 133 columns wide The phrase substitution landed without rewrapping the paragraph around it, so a commit whose whole purpose was making four comments readable left one of them the widest line in its file by twenty-nine columns, wrapping mid-link in any ordinary editor. Biome does not reflow prose, so no gate caught it. Whitespace only: the sentence is unchanged and the paragraph now wraps at the same width as the two above it. --- server/src/plugins/google-drive-rest.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 6d3965017..b7a5865b9 100644 --- a/server/src/plugins/google-drive-rest.ts +++ b/server/src/plugins/google-drive-rest.ts @@ -15,8 +15,9 @@ import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp"; * WHAT MAKES IT SWAPPABLE. This module implements the interface {@link ./mcp} already had — * `listTools` and `callTool`, same shapes — rather than inventing one for itself. MCP is therefore * not the default with an exception carved out of it; both are implementations of the same contract, - * chosen per catalogue entry by {@link ./access} and looked up in {@link ./transport}. Going back to the MCP server when the preview - * opens is one field on one entry, with nothing else in the system aware it changed. + * chosen per catalogue entry by {@link ./access} and looked up in {@link ./transport}. Going back to + * the MCP server when the preview opens is one field on one entry, with nothing else in the system + * aware it changed. * * The TOOL NAMES are deliberately the ones Google's MCP server advertises, character for character. * A grant is stored as `google-drive/search_files`, so keeping the names identical means every grant From 88193ff6a9422c028ee5f844a5c9130b580465f2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:16:26 +0200 Subject: [PATCH 21/93] Refuse a brokered call nobody is attributed for, and one whose asker has not connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brokered app holds one deployment key and relies on Composio to keep people's accounts apart, so the only thing that separates them is the person id the call runs under. Two states therefore have to be refused before anything is spent at the vendor: a run attributed to nobody, and a person who has not connected that app at all. Both are refused in `connectionTokenFor`, beside the `user-oauth` refusals they are modelled on, for the same two reasons — the person gets a sentence naming the step they can take, and no call is spent finding out. The transport refuses again as a last line. `connectionTokenFor` now takes the `ServerAccess` descriptor its callers already hold, and the per-person branch reads `access.credential` rather than the catalogue entry's auth kind, so both branches decide from one source instead of two that can disagree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 78 +++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index dbdc3d499..7e424417d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -21,6 +21,7 @@ import type { Database } from "../db/client"; import { agentProfiles, agents, + composioConnections, // Aliased: `credentials` is already the injected vault interface in this module, and the table and // the interface are two different things to reach for. credentials as credentialRows, @@ -764,11 +765,57 @@ export function createPluginStore(options: PluginStoreOptions) { * of revocation being complete by construction rather than by cleanup. */ async function connectionTokenFor( - row: { id: string; url: string; credentialId: string | null }, + row: { + id: string; + url: string; + title: string; + credentialId: string | null; + }, entry: CatalogueEntry | null, actorId: string, + access: ServerAccess, ): Promise<{ token?: string }> { - if (entry?.auth.kind !== "user-oauth") { + /* + * A brokered app, where the deployment holds one key and Composio keeps the accounts apart. + * + * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below is: + * a person gets a sentence naming the step they can take, and no call is spent at the vendor + * finding out. The transport refuses again as a last line, which is the belt to this braces — + * deleting either one has to turn a test red. + * + * There is no token. The key belongs to the transport and never travels through this function, so + * nothing here can leak it into a connection object, an error or an audit row. + */ + if (access.credential === "brokered") { + if (!actorId) { + throw new PluginRefusedError( + `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, + null, + ); + } + + const [connected] = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, row.id), + eq(composioConnections.userId, actorId), + ), + ) + .limit(1); + + if (!connected) { + throw new PluginRefusedError( + `You have not connected your ${row.title} account. Connect it in Settings and ask again.`, + null, + ); + } + + return {}; + } + + if (access.credential !== "person-oauth") { const token = row.credentialId ? await secretFor( row.credentialId, @@ -778,6 +825,24 @@ export function createPluginStore(options: PluginStoreOptions) { return { token }; } + /* + * Narrowing, not a second decision. + * + * `access.credential === "person-oauth"` is derived in `access.ts` from exactly this auth kind, + * so the branch above has already established it — but the derivation runs through a lookup + * table the compiler cannot follow back to `entry`. Nothing below re-decides whether this is a + * per-person server; it only reads the OAuth details that kind carries. + * + * A throw rather than a fallback. If the descriptor and the entry ever did disagree, answering + * out of the deployment's own credential is precisely the failure the comment above this function + * says must be impossible. + */ + if (entry?.auth.kind !== "user-oauth") { + throw new Error( + `${row.id} resolves to a per-person credential with no user-oauth catalogue entry.`, + ); + } + /* * The anonymous actor is the empty string, and an empty string must never match a row. * @@ -2011,7 +2076,7 @@ export function createPluginStore(options: PluginStoreOptions) { * a function that discards it. The gate outlived the reason for it. */ const token = transport.listNeedsCredential - ? (await connectionTokenFor(row, entry, actorId)).token + ? (await connectionTokenFor(row, entry, actorId, access)).token : undefined; const tools = await transport.listTools({ @@ -3069,7 +3134,12 @@ export function createPluginStore(options: PluginStoreOptions) { * it did. */ try { - const { token } = await connectionTokenFor(row, entry, input.actorId); + const { token } = await connectionTokenFor( + row, + entry, + input.actorId, + access, + ); const vendor = injectedVendor ?? transportFor(access.transport).callTool; const result = await vendor( From 9045a52192bd08627c512f2a03df7a7b24d61316 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:35:00 +0200 Subject: [PATCH 22/93] Test the two refusals that stand between a stray run and somebody else's mailbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brokered branch of `connectionTokenFor` refuses two things: a run that is not attributed to anybody, and an asker who has not connected the app. Both shipped with nothing in the suite exercising them. A throw planted as the first statement inside the `if (access.credential === "brokered")` branch left the whole suite green at 1883 pass — nothing entered that arm, so either refusal could have been deleted without a single test noticing. Silently deletable is the worst state for a security refusal to be in. Two tests now enter it, and each goes red when its own refusal is deleted. Removing the `if (!actorId)` block makes the unattributed test fail on the wrong refusal reaching it; removing the `if (!connected)` block makes the not- connected test fail because the call resolves and the stub records a slug, which is a call spent at the broker to find out what the row already knew. Both assert that the vendor stub was never reached, so the refusal is proven to happen before a call is spent rather than merely somewhere. The docblock above the function had stopped enumerating the branches it governs while the code appealed to it as authority. It described a two-case function keyed on auth kinds the body no longer reads, said nothing about the brokered arm that carries both refusals, and stated unconditionally that a refresh token is exchanged per call — telling a reader on the brokered path that something is exchanged where no refresh token exists at all. It now names all four kinds, and says which paths have nothing to cache versus the one where not caching is a decision. `row.url` went with it. It was read nowhere in the function, and dropping it fits the parameter back onto one line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 35 ++++++----- server/tests/plugin-store.integration.test.ts | 61 +++++++++++++++++-- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 7e424417d..edd425881 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -748,29 +748,36 @@ export function createPluginStore(options: PluginStoreOptions) { } /** - * The token one call goes out with, and whose it is. + * The token one call goes out with, and whose it is — decided from `access.credential`, so that + * this function and the audit row cannot disagree about whose account a call ran in. * - * For a `deployment-bearer` server this is what it always was: the one credential an administrator - * gave the server, used for everybody. + * For a `deployment-token` server this is what it always was: the one credential an administrator + * gave the server, used for everybody. A `none` server reaches the same branch and finds nothing + * to decrypt, which is the right answer for an endpoint that takes no credential at all. * - * For a `user-oauth` server it is the asker's own, and every branch that cannot prove it has the + * For a `brokered` server there is no token here AT ALL. The deployment's one key belongs to the + * transport and never travels through this function, so nothing here can leak it into a connection + * object, an error or an audit row. What this function contributes instead is the two refusals + * that have to happen before a call is spent at the broker: a run nobody is attributed for, and an + * asker who has not connected the app — so a person is told their own next step rather than shown + * the broker's error about an account it cannot find. + * + * For a `person-oauth` server it is the asker's own, and every branch that cannot prove it has the * asker's grant refuses. There is deliberately no fallback. A fallback is the one bug this design * exists to make impossible: answering out of whatever the deployment, or the last person to * connect, happened to be able to see — which returns a confident answer assembled from documents * the person asking cannot open, and looks exactly like a correct answer. * - * Nothing is cached. The refresh token is exchanged for an access token per call and the access - * token is thrown away, so there is no stored copy of anybody's access for a disconnect to have to - * find. That costs a round trip to the vendor's token endpoint on every call, which is the price - * of revocation being complete by construction rather than by cleanup. + * Nothing is cached on any path, and only on the `person-oauth` one is that a decision. There, the + * refresh token is exchanged for an access token per call and the access token is thrown away, so + * there is no stored copy of anybody's access for a disconnect to have to find. That costs a round + * trip to the vendor's token endpoint on every call, which is the price of revocation being + * complete by construction rather than by cleanup. The other two paths have nothing to cache: a + * `deployment-token` is decrypted out of the vault per call, and a `brokered` key is never held + * here at all. */ async function connectionTokenFor( - row: { - id: string; - url: string; - title: string; - credentialId: string | null; - }, + row: { id: string; title: string; credentialId: string | null }, entry: CatalogueEntry | null, actorId: string, access: ServerAccess, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 9884ae9a2..aed24413c 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3245,8 +3245,8 @@ describe("a vendor reply that is not a token", () => { * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; * the `afterAll` below is what stops the last test's fixtures from outliving the run. * - * The fixtures below this one are `export`ed for one reason: they are shared scaffolding, and a - * fixture whose first caller has not been written yet reads to the linter as dead code. + * {@link seedNotionServer} below is `export`ed for one reason: it is shared scaffolding whose first + * caller has not been written yet, and a fixture nothing calls reads to the linter as dead code. */ async function freshDatabase(): Promise { /* @@ -3291,7 +3291,7 @@ async function freshDatabase(): Promise { * properties under test, and both are decided on the way to the vendor — so the real path has to * run, and the vendor is stubbed further out at {@link useComposioClient}. */ -export async function freshStore() { +async function freshStore() { const database = await freshDatabase(); const persisting = createAuditStore(database); const events: Parameters[0][] = []; @@ -3315,7 +3315,7 @@ export async function freshStore() { } /** A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. */ -export async function seedComposioGmail( +async function seedComposioGmail( database: Database, store: PluginStore, options: { connect?: boolean } = {}, @@ -3483,6 +3483,59 @@ test("a Composio app is listed through the Composio transport, not dialled as MC expect(asked).toEqual(["gmail"]); }); +test("a Composio call with nobody attributed is refused before it reaches the vendor", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + await seedComposioGmail(database, store); + + // The empty string is what the actor resolves to when nobody could be identified. Reaching the + // vendor with it would run in whatever account Composio has against "", or in nobody's, and either + // way the run is unattributable — the state every identity defect in OpenTag started from. + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "", + }), + ).rejects.toThrow(/not attributed to anybody/i); + + expect(reached).toEqual([]); +}); + +test("a Composio call by somebody who has not connected the app is refused with a sentence they can act on", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + await seedComposioGmail(database, store, { connect: false }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Refused here rather than at Composio, so a person is told what to do instead of being shown + // somebody else's error, and so no call is spent finding out. + expect(reached).toEqual([]); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); From 1cc7b82372b2d3a5d727d0fc7642f76a21ab322e Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:50:43 +0200 Subject: [PATCH 23/93] Hand the recorded effect to the classifier wherever a tool is classified Both call sites in the plugin store still called classifyTool with three arguments, so every Composio action was classified by the catalogue's hand-written write list. A Composio app has no such list and no catalogue entry at all, so every action read as a write regardless of the effect the vendor recorded when the action was listed. callTool now selects the recorded effect alongside the input schema and passes it; listServers already selected every column, so its tool map only needed the argument. destructive and version are selected in callTool but not yet read: version is consumed by a later change and destructive by the confirmation card, and selecting them now keeps that a one-line diff rather than a re-shaped query. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index edd425881..0c4c48663 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2278,7 +2278,7 @@ export function createPluginStore(options: PluginStoreOptions) { description: tool.description, inputSchema: tool.inputSchema as Record, ref, - effect: classifyTool(entry, tool.name, true), + effect: classifyTool(entry, tool.name, true, tool.effect), grantedTo: grants.get(ref) ?? [], }; }), @@ -2984,14 +2984,25 @@ export function createPluginStore(options: PluginStoreOptions) { const { row, entry, access } = await requireServer(serverId); const advertised = await database - .select({ name: mcpTools.name, inputSchema: mcpTools.inputSchema }) + .select({ + name: mcpTools.name, + inputSchema: mcpTools.inputSchema, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) .from(mcpTools) .where( and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)), ) .limit(1); - const effect = classifyTool(entry, toolName, advertised.length > 0); + const effect = classifyTool( + entry, + toolName, + advertised.length > 0, + advertised[0]?.effect, + ); const args = withoutEmptyOptionals( input.args, From 9b1b060502ba84c8b2cc758e21bbb93151696b87 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:10:48 +0200 Subject: [PATCH 24/93] Test both places a brokered action's effect is read, not just the call path The recorded effect was already being selected and passed at both call sites, but nothing asserted the result at either one. The call path's audit row and the admin page's listing both depended on it and neither would have said so if the argument went away. The admin-page site had no coverage at all, and it needed nothing deferred to test it: listServers reads the seeded row directly, so a plain assertion on the returned tool's effect is enough. Each test now fails only when its own call site regresses. Reverting the callTool call to three arguments fails the audit-row test alone; reverting the listServers call fails the listing test alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index aed24413c..374fe7b4d 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3623,3 +3623,56 @@ test("an action listed before these columns existed reads as unclassified and un // reclassify every already-listed Notion read as a write the moment the migration ran. expect(row).toEqual({ effect: null, destructive: false, version: null }); }); + +test("a brokered call is judged by the effect the vendor recorded, not by the absent catalogue entry", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => ({}), + }); + // `effect: "read"` on the seeded action, and no catalogue entry for `gmail` at all — so the two + // sources disagree and the row records which one decided. + expect(catalogueEntry("gmail")).toBeNull(); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The call itself does not complete: nothing hands the transport the recorded version yet, so it + // refuses rather than guessing one. Irrelevant to what is under test — `effect` is decided before + // the vendor is dialled and the failure row carries the decision either way, which is the whole + // point of holding `decided` rather than writing it. + expect(result.isError).toBe(true); + + const call = auditStore + .recorded() + .find((event) => event.eventType === "mcp.call_failed"); + // "read", because Composio labelled the action and the classifier prefers that label. "write" is + // what an unlisted-in-`writeTools` tool on a server with no entry behind it comes out as, and that + // is what this row said while the recorded effect was being selected and never passed on: every + // Gmail read gated as a write, and `intent` in the policy context reading `write_tool`. + expect((call?.payload as { effect?: string } | undefined)?.effect).toBe( + "read", + ); +}); + +test("the Plugins page shows a brokered action with the effect the vendor recorded", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Same disagreement as the call path, on the surface an administrator reads: no catalogue entry + // behind `gmail`, so the reviewed-list branch has nothing to say and would call every one of the + // app's actions a write. Shown as a write, this page tells an administrator that granting a Bot + // "fetch emails" grants it something that changes their mailbox. + expect( + gmail?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), + ).toEqual([{ name: "GMAIL_FETCH_EMAILS", effect: "read" }]); +}); From 20c738c190723a034cb8c73e61af60987eb5855c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:13:11 +0200 Subject: [PATCH 25/93] Say in the classifier's own doc that a vendor's label can earn a read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block claimed a server with no catalogue entry behind it was a write throughout, because nothing reviewed said any tool of theirs only reads. A test in this same branch asserts the opposite: composio-classify.test.ts takes a recorded `read` on a null entry and expects a read, as does the store integration test just added for the brokered call path. It also described the advertised-and-absent-from-the-write-list case as the only way to produce a read, and said so as "it is the only one". A second read-producing case has existed unmentioned since the recorded effect was introduced: an advertised action whose recorded effect is exactly `read`, which is a read regardless of the write list and regardless of whether an entry exists. The doc now names both sources, the order they are consulted in, and both ways a read can be earned. The reconciliation helper's justification inherited the same stale premise. Its conclusion is still right — an entry-less server is not reconciled — but the reason is no longer that all of its tools are already writes. It is that a brokered app is classified from the vendor's per-action label rather than from a hand-written list, so there is no under-inclusion here to find. Comments only; no behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/catalogue.ts | 20 +++++++++++++------- server/src/plugins/store.ts | 5 +++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index ab44f70be..4205fd959 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -361,14 +361,20 @@ export function resolveServerUrl( /** * What this tool does, in the only two categories a policy author cares about. * - * Unknown counts as a write. A tool named in {@link CatalogueEntry.writeTools} is a write. A tool - * the server never advertised at all is a write, because the only thing that produced the name was - * a model. A server with no catalogue entry behind it is a write throughout, because nothing - * reviewed says any tool of theirs only reads. + * TWO SOURCES, CONSULTED IN ORDER. What the vendor recorded about this action when it was listed + * comes first: exactly `read` is a read, and anything else — a recorded write, an unrecognised + * value, a different case — is a write. Only where nothing was recorded does the reviewed write list + * decide, and there a tool the entry names is a write while one it does not name is a read. * - * Only a tool the server itself listed AND that is absent from the write list is treated as a read. - * That is the one case where both sources agree, and it is the only one where guessing permissively - * is recoverable. + * Unknown counts as a write throughout. A tool the server never advertised at all is a write, + * because the only thing that produced the name was a model. A server with no catalogue entry behind + * it is a write unless the vendor recorded a read for that action — there is no reviewed list to + * consult, so an unlabelled action of theirs has nothing saying it is safe. + * + * So there are two ways to earn a read, and both require somebody to have said so. Either the vendor + * labelled the action a read, or the server advertised it and a reviewed list declined to call it a + * write. Guessing permissively is recoverable only in those two cases; everywhere else the answer is + * a write. */ export function classifyTool( entry: CatalogueEntry | null, diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 0c4c48663..8af7a3e21 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -293,8 +293,9 @@ export function refFromToolName(toolName: string): string | null { * the vendor. Naming those would be noise in front of the one case that has no second barrier at all * — Notion, whose access is per-page on a consent screen and whose `scopes` are therefore empty. * - * A server with no catalogue entry is not reconciled either, and for the opposite reason: nothing - * reviewed says any tool of theirs only reads, so all of them are already writes. + * A server with no catalogue entry is not reconciled either, and for a different reason: a brokered + * app's actions are classified from the vendor's own per-action label rather than from a list here, + * so there is no hand-written under-inclusion to find. * * Sorted, so two readings of the same listing produce the same row. */ From ac45f16781fead492f44a1e631b674dbfe9b2890 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:28:49 +0200 Subject: [PATCH 26/93] Name both entry-less shapes in the reconciliation helper's reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph justifying the early return covered one shape at a time and swapped which one it was wrong about. The sentence this branch deleted was true of a server an administrator added by URL and false of a brokered app; its replacement was true of the brokered app and silent-to-misleading for the other, while its opening clause still matched both. A reader with a custom server in mind matched on the opening and was handed a reason that does not hold for them — nothing populates a per-action label for an MCP server, and nothing will, so every tool it offers stays a write. Both shapes are now named with the reason that actually applies to each. Comment only; the return itself was right throughout. --- server/src/plugins/store.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 8af7a3e21..5b88817da 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -293,9 +293,11 @@ export function refFromToolName(toolName: string): string | null { * the vendor. Naming those would be noise in front of the one case that has no second barrier at all * — Notion, whose access is per-page on a consent screen and whose `scopes` are therefore empty. * - * A server with no catalogue entry is not reconciled either, and for a different reason: a brokered - * app's actions are classified from the vendor's own per-action label rather than from a list here, - * so there is no hand-written under-inclusion to find. + * A server with no catalogue entry is not reconciled either, and the two shapes that reach here do + * so for different reasons. A brokered app's actions are classified from the vendor's own + * per-action label rather than from a list here, so there is no hand-written under-inclusion to + * find. A server an administrator added by URL has neither a label nor a list, so every tool it + * offers is already a write and there is no wrongly-permitted read to reconcile. * * Sorted, so two readings of the same listing produce the same row. */ From f7a99112db0219dd6e0bf060df76bf3b7c605439 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:37:26 +0200 Subject: [PATCH 27/93] Record what a refresh learned about each action Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5b88817da..84494c0ca 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2121,6 +2121,16 @@ export function createPluginStore(options: PluginStoreOptions) { name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes any of this today, so all three stay null or false for + * every other transport — which is what keeps `classifyTool` falling back to the + * curated write list for Notion and Drive exactly as it did before. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version ?? null, })), ); } From f8f95a9a71b3d8e3c78a0e6f18c3757ef674c889 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 21:49:32 +0200 Subject: [PATCH 28/93] Gate the three columns a refresh now writes, and guard the two vendors it must not touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path shipped with nothing exercising it. `refreshTools` records an effect, a destructive marker and a version for every action a listing returns, and all three keys could be deleted from that insert one at a time with the whole suite staying green. Each deletion, and a revert of the block entire, is now a failure of the Composio refresh test: it asserts the three answers the vendor's listing gave — a label, a marker, and the version a call is impossible without — against the three columns the rows come back with. The nearest thing that already existed inserts into `mcp_tools` directly and never calls the refresh, so what it asserted was that the column round-trips a value, not that the refresh is what puts one there. A column that round-trips is not a column anything writes to. The second test is not a gate for this change but for its opposite. No Notion or Drive refresh may come out of that insert any differently than it did before: those transports publish none of the three, so their rows have to keep a null recorded effect, and `classifyTool` has to go on deciding from the reviewed write list — a read for the name it does not cover, a write for the one it does. So it passes both with the hunk and without it, and goes red only when the coalesce is altered to record a write where the vendor said nothing, which is the mutation that would silently reclassify every Notion and Drive read and change what the policy engine refuses for connectors nobody touched. Across the store, plugin-routes, Drive, routines and Composio-transport suites it is the only test that catches that. It sits inside the evicted-dynamic-client suite because that is the only place in this file where a `user-oauth` listing can be made to really happen. The plan's version of it refreshed a per-person server as somebody holding no grant, so the refresh refused, no rows were written, and an `every`-style assertion over an empty list was trivially true — it was green under the very mutation above. This one connects a grant first, asserts the listing returned two tools, and compares both rows by value, so an empty list fails. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 374fe7b4d..6c56c19a0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2612,6 +2612,126 @@ describe("a dynamic client the vendor has evicted", () => { expect(named).not.toContain("notion-create-pages"); }); + /** + * The classification an MCP server has always had, over a real listing that really happened. + * + * The three columns the refresh now writes are Composio's, and every other transport has to keep + * coming out of that insert as null and false — because `classifyTool` prefers a recorded effect to + * the reviewed write list, so a value appearing here for Notion would silently reclassify every one + * of its reads as a write, on a connector nobody touched. Asserted on the rows AND on what the + * Plugins page derives from them, because it is the second one that an administrator reads. + * + * It lives in this suite because this is the only place a `user-oauth` listing can actually be + * made to happen: the refresh runs on the grant of whoever pressed the button, so a Notion row with + * nobody connected records a refusal in `lastError` and writes no tools at all — which is a test + * that passes by having nothing to check. + */ + test("a refreshed MCP server records no effect, no marker and no version", async () => { + await putClient(EVICTED); + await connect(); + accepted = new Set([EVICTED.clientId]); + + const mock = new MCPMock(); + mock + .addTool({ + name: "notion-fetch", + description: "A read no write list names.", + inputSchema: { type: "object", properties: {} }, + }) + .addTool({ + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, + }); + const mockUrl = await mock.start(); + + // What the deployment currently advertises for this server, because a refresh replaces the list + // wholesale and this one is pointing the vendor at a mock. + const advertisedBefore = await database + .select() + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)); + const [stampBefore] = await database + .select({ + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + lastError: mcpServers.lastError, + }) + .from(mcpServers) + .where(eq(mcpServers.id, dynamicServerId)); + + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const target = String(input instanceof Request ? input.url : input); + return realFetch( + target.startsWith("https://mcp.notion.com") ? mockUrl : input, + init, + ); + }) as typeof fetch; + + try { + // Two tools listed, so the assertions below have something to be about: `every` over an empty + // list is true, and a refusal recorded in `lastError` would leave exactly that. + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "notion-create-pages", + effect: null, + destructive: false, + version: null, + }, + { + name: "notion-fetch", + effect: null, + destructive: false, + version: null, + }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); + + // The reviewed write list, still deciding: the name it covers is a write and the name it does + // not is a read. A recorded effect on either row is what would take this over. + expect( + listed?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + } finally { + globalThis.fetch = realFetch; + await mock.stop?.(); + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)); + if (advertisedBefore.length > 0) { + await database.insert(mcpTools).values(advertisedBefore); + } + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, + lastError: stampBefore?.lastError ?? null, + }) + .where(eq(mcpServers.id, dynamicServerId)); + } + }); + /** * What a failed refresh writes into `lastError`, and how much of it. * @@ -3676,3 +3796,78 @@ test("the Plugins page shows a brokered action with the effect the vendor record gmail?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), ).toEqual([{ name: "GMAIL_FETCH_EMAILS", effect: "read" }]); }); + +test("refreshing a Composio app records each action's effect, destructive marker and version", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }, + { + slug: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + inputParameters: { type: "object", properties: {} }, + tags: ["destructiveHint"], + version: "20260903_00", + }, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => ({}), + }); + + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await store.refreshTools("gmail", "admin_user"); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")) + .orderBy(asc(mcpTools.name)); + + // The vendor's own three answers, as the listing gave them: a label, a marker, and the version a + // call is impossible without. `createHint` is a write with no marker — an ordinary write is not + // dangerous, and marking it so teaches an approver to click through the colour. + expect(rows).toEqual([ + { + name: "GMAIL_DELETE_MESSAGE", + effect: "write", + destructive: true, + version: "20260903_00", + }, + { + name: "GMAIL_FETCH_EMAILS", + effect: "read", + destructive: false, + version: "20260903_00", + }, + { + name: "GMAIL_SEND_EMAIL", + effect: "write", + destructive: false, + version: "20260903_00", + }, + ]); +}); From b1170926c1a747a5c0172ba82b637fc055dcaab0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 21:54:24 +0200 Subject: [PATCH 29/93] Call a Composio action at the version it was listed at The transport has refused every brokered call for want of a version since it started requiring one: the recorded version was selected in `callTool` and never passed on. It now travels to the vendor under the reserved key the Composio transport strips before dispatch. Merged last, after the model's own arguments, so a `__version` a model supplied cannot decide which revision of an action runs. Absent when the app has not been refreshed since the column existed, which the transport still refuses on rather than guessing. The audit payload is unchanged and derives from the decision, not from the arguments, so the reserved key never reaches the trail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 84494c0ca..3e98e1f4d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -41,6 +41,7 @@ import { serverCredentialKind, } from "./catalogue"; import { accessFor, type ServerAccess } from "./access"; +import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; import { McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; @@ -3022,6 +3023,25 @@ export function createPluginStore(options: PluginStoreOptions) { advertised[0]?.inputSchema as Record | undefined, ); + /* + * The version this action was listed at, handed to the transport that needs one. + * + * Under a reserved key rather than as a parameter on the shared signature, because that + * signature is MCP's and three other transports implement it. The Composio transport strips the + * key before anything reaches the vendor, and asserts that it did. + * + * The recorded version is merged LAST, so a `__version` a model supplied in its own arguments + * cannot win. Reversing the spread would let a model choose which revision of an action runs — + * a revision that was never listed, classified or granted. + * + * Absent when the app has not been refreshed since the column existed, which the transport + * refuses on rather than guessing — a guessed version is a call against an action's other + * behaviour. + */ + const vendorArgs = advertised[0]?.version + ? { ...args, [VERSION_ARG]: advertised[0].version } + : args; + /** * The same policy the computer actions are judged by, asked about a tool call. * @@ -3181,7 +3201,7 @@ export function createPluginStore(options: PluginStoreOptions) { botId: input.botId, }, toolName, - args, + vendorArgs, ); await recordAuditEvent(auditStore, { eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded", From b253c99c5748362909b55293635a149733517be4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 21:54:32 +0200 Subject: [PATCH 30/93] Close the two audit gates that were waiting on a call that could complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests were specified five and four waves ago and could not pass: no Composio call could complete for want of a version, so every brokered call ended in a refusal from the transport and the event written was a failure rather than a success. The audit gate has therefore been green-under-mutation since it was written — nothing could reach the success row it asserts about. The version test catches passing `args` instead of `vendorArgs` to the vendor call. The audit test catches `reachedAsFor` deriving whose account was reached from the entry's auth kind rather than from the resolved descriptor, and `callTool` resolving its transport from the entry-derived shape rather than from `access.transport`. The effect test above them asserted the refusal as an aside and said so in a comment; with a version now sent, the call completes and the row it reads is `mcp.call_succeeded`. The property it tests is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 6c56c19a0..90e0ea7b8 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3656,6 +3656,56 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +test("a Composio call sends the version recorded for that action", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, _userId, version) => { + calls.push({ slug, version }); + return {}; + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(calls).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, + ]); +}); + +test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => ({ messages: [] }), + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + const call = auditStore + .recorded() + .find((event) => event.eventType === "mcp.call_succeeded"); + + // The single question a per-person connector raises: whose account did this reach. Recorded as + // "deployment", the trail is wrong about exactly the thing this connector exists for — two rows for + // the same action and the same Bot can have touched two different mailboxes, and nothing else in + // the row says which. + expect(call?.payload).toMatchObject({ reachedAs: "user_asker" }); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); @@ -3762,15 +3812,14 @@ test("a brokered call is judged by the effect the vendor recorded, not by the ab actorId: "user_asker", }); - // The call itself does not complete: nothing hands the transport the recorded version yet, so it - // refuses rather than guessing one. Irrelevant to what is under test — `effect` is decided before - // the vendor is dialled and the failure row carries the decision either way, which is the whole - // point of holding `decided` rather than writing it. - expect(result.isError).toBe(true); + // The call completes: the transport is handed the version this action was listed at. Irrelevant to + // what is under test — `effect` is decided before the vendor is dialled and the row carries the + // decision on either outcome, which is the whole point of holding `decided` rather than writing it. + expect(result.isError).toBe(false); const call = auditStore .recorded() - .find((event) => event.eventType === "mcp.call_failed"); + .find((event) => event.eventType === "mcp.call_succeeded"); // "read", because Composio labelled the action and the classifier prefers that label. "write" is // what an unlisted-in-`writeTools` tool on a server with no entry behind it comes out as, and that // is what this row said while the recorded effect was being selected and never passed on: every From ab46ac58afec927503259b3df61822e8c2975eb7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:02:27 +0200 Subject: [PATCH 31/93] Stop a model choosing which revision of an action it calls `callTool` hands the Composio transport the version the action was listed at by merging it into the caller's arguments. The merge order is the whole of the protection: the recorded version goes in LAST, so a `__version` a model put into its own arguments is overwritten rather than honoured. Nothing strips a non-empty value on the way in, so that spread is the only thing standing between a model's chosen version and the vendor. Nothing in the repository detected the reversal. Flipping it to `{ [VERSION_ARG]: advertised[0].version, ...args }` left the store integration and transport suites entirely green -- no test put a `__version` into the caller's own arguments through the store, and the tests that mention the key call the transport directly, so they bypass the merge. Reversed, a model could pick which revision of a granted action runs: a revision that was never listed, never classified and never granted, whose arguments and behaviour are not the ones an administrator reviewed. The audit row would name the action and the person it ran as, but not which revision of it actually executed -- so the trail could not answer the one question the defect raises. This test asks for a seeded action whose recorded version is `20260903_00` while supplying `19700101_00` in the model's own arguments, and asserts the vendor was called with the recorded one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 90e0ea7b8..f26b84fa3 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3680,6 +3680,36 @@ test("a Composio call sends the version recorded for that action", async () => { ]); }); +test("a version a model supplied in its own arguments cannot beat the recorded one", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, _userId, version) => { + calls.push({ slug, version }); + return {}; + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + // A model filling in the reserved key itself. It reaches `args` intact — nothing strips a + // non-empty value — so the only thing standing between it and the vendor is that the recorded + // version is merged after it. + args: { __version: "19700101_00" }, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The listed revision, not the one the model asked for. Reversed, this call would run against a + // revision that was never listed, never classified and never granted — and the audit row would + // name the action without naming which of its revisions actually ran. + expect(calls).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, + ]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From 80d411771c96038dec441705686a8350fc016953 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:22:28 +0200 Subject: [PATCH 32/93] Strip the reserved version key before trusting either branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge order that guards `__version` covered only one of the two branches: where a version was recorded, merging it last meant a `__version` a model put in its own arguments could not win. Where none was recorded, `vendorArgs` was `args` itself, so the model's key was the only version present and the transport honoured it — nothing strips it on the way in, since `withoutEmptyOptionals` filters only on the empty string. That ran a granted action at a revision that was never listed, never classified and never granted, with arguments and behaviour that are not the ones an administrator reviewed. The audit row carries no version field, so the trail named the action and the person but could not say which revision actually ran. Reachable rather than a migration artifact: `ComposioAction.version` is optional and the transport maps it conditionally, so any action Composio lists without a version leaves a null column on a row that stays granted and callable. The key is now dropped unconditionally, before the recorded one is merged, so the absent-version case refuses at the transport exactly as its comment always claimed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 3e98e1f4d..fd7434b97 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3034,13 +3034,21 @@ export function createPluginStore(options: PluginStoreOptions) { * cannot win. Reversing the spread would let a model choose which revision of an action runs — * a revision that was never listed, classified or granted. * - * Absent when the app has not been refreshed since the column existed, which the transport - * refuses on rather than guessing — a guessed version is a call against an action's other + * Absent when the app has not been refreshed since the column existed, and because the key is + * stripped below there is then no version at all for the transport to read, which is what makes + * its refusal hold rather than guessing — a guessed version is a call against an action's other * behaviour. */ + + /* + * A `__version` in the model's own arguments is not an argument: it is this key, and no vendor + * publishes it. Dropped before the recorded one is merged, so the absent-version case refuses at + * the transport rather than running at a revision a model chose. + */ + const { [VERSION_ARG]: _dropped, ...modelArgs } = args; const vendorArgs = advertised[0]?.version - ? { ...args, [VERSION_ARG]: advertised[0].version } - : args; + ? { ...modelArgs, [VERSION_ARG]: advertised[0].version } + : modelArgs; /** * The same policy the computer actions are judged by, asked about a tool call. From bfd8506c2a2ba79c3a29a132828b52ea2b45cc04 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:22:36 +0200 Subject: [PATCH 33/93] Gate the branch where no version was recorded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test covers the branch that has a recorded version: a model's `__version` loses to it because the recorded one is merged last. The other branch had nothing merged after the model's key, and no test asked what happened there. Seeded with `version: null` — the row an action Composio lists without a version leaves behind — the call must come back as the transport's refusal naming the refresh, and the vendor stub's `execute` must never be reached. Before the fix it was reached, at `19700101_00`: the version the model named, not one this deployment ever listed. `seedComposioGmail` takes the version as an option beside `connect`, so the fixture ids the suite owns outright stay the ones the ownership guard and teardown cover. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index f26b84fa3..5ae6fa0c1 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3434,11 +3434,17 @@ async function freshStore() { return { store, database, auditStore }; } -/** A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. */ +/** + * A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. + * + * `version: null` is the action Composio listed without one — a granted, callable row whose version + * column is null, which is a state the vendor's own optional field produces rather than a leftover + * from before the column existed. + */ async function seedComposioGmail( database: Database, store: PluginStore, - options: { connect?: boolean } = {}, + options: { connect?: boolean; version?: string | null } = {}, ) { await database.insert(mcpServers).values({ id: "gmail", @@ -3452,7 +3458,7 @@ async function seedComposioGmail( name: "GMAIL_FETCH_EMAILS", description: "Fetch emails.", effect: "read", - version: "20260903_00", + version: options.version === undefined ? "20260903_00" : options.version, }); await database.insert(agents).values({ id: "bot_helper", @@ -3710,6 +3716,39 @@ test("a version a model supplied in its own arguments cannot beat the recorded o ]); }); +test("a version a model supplied cannot stand in for an action with none recorded", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, _userId, version) => { + calls.push({ slug, version }); + return {}; + }, + }); + // The action with no recorded version, which is the branch the test above does not cover: there is + // nothing to merge last, so the merge order guards nothing and the model's key is the only version + // in the arguments. + await seedComposioGmail(database, store, { version: null }); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: { __version: "19700101_00" }, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The transport's refusal, which is the advertised answer for an action with no recorded version — + // and an operator's one-click fix, rather than a call against a revision a model named. + expect(result.isError).toBe(true); + expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); + + // Never dialled. Honoured, the model's version runs a granted action at a revision that was never + // listed, never classified and never granted, and the audit row carries no version field to say + // which revision that was. + expect(calls).toEqual([]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From 010e7e8ea22c6c647bdb9cf16dcc2c0677108e56 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:34:21 +0200 Subject: [PATCH 34/93] Name the strip as what protects the version, not the merge order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unconditional strip of the reserved key made both spread orders identical: the recorded version is merged into arguments that provably cannot carry the key, so merge order no longer has a reachable failure mode. Three comments still presented that order as the protection. A reader who believes it concludes the strip is redundant belt-and-braces, removes it, and reopens a real hole — with the suite green. They now name the strip as the mechanism and the ordering as the redundant half. The test guarding the order is consequently inert: it fails under none of a full revert of the strip, a strip-present-but-fallback-raw, or a reversal, and its assertion is already covered elsewhere. It is kept for the property it documents, and now says plainly that it is not a mutation gate rather than reading as one. In the remaining gate, the never-dialled assertion moves above the two refusal assertions, so a regression names the version that reached the vendor instead of reporting `Expected: true / Received: false`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 28 +++++++------ server/tests/plugin-store.integration.test.ts | 39 ++++++++++++------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index fd7434b97..edbec295e 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3027,23 +3027,21 @@ export function createPluginStore(options: PluginStoreOptions) { * The version this action was listed at, handed to the transport that needs one. * * Under a reserved key rather than as a parameter on the shared signature, because that - * signature is MCP's and three other transports implement it. The Composio transport strips the - * key before anything reaches the vendor, and asserts that it did. + * signature is MCP's and three other transports implement it. The Composio transport strips + * the key before anything reaches the vendor, and asserts that it did. * - * The recorded version is merged LAST, so a `__version` a model supplied in its own arguments - * cannot win. Reversing the spread would let a model choose which revision of an action runs — - * a revision that was never listed, classified or granted. + * A `__version` in the model's own arguments is not an argument: it is this key, and no + * vendor publishes it. So it is stripped unconditionally, whatever its value, and that strip + * is the whole protection. The recorded version is then merged into arguments that provably + * cannot carry the key, which makes both spread orders identical: the merge order has no + * reachable failure mode. Do not read the strip as belt-and-braces on top of an ordering + * guarantee — the ordering is the redundant half, and removing the strip is what would let a + * model choose which revision of an action runs. * - * Absent when the app has not been refreshed since the column existed, and because the key is - * stripped below there is then no version at all for the transport to read, which is what makes - * its refusal hold rather than guessing — a guessed version is a call against an action's other - * behaviour. - */ - - /* - * A `__version` in the model's own arguments is not an argument: it is this key, and no vendor - * publishes it. Dropped before the recorded one is merged, so the absent-version case refuses at - * the transport rather than running at a revision a model chose. + * Absent when the app has not been refreshed since the column existed, and because the key + * was stripped there is then no version at all for the transport to read, which is what makes + * its refusal hold rather than guessing — a guessed version is a call against an action's + * other behaviour. */ const { [VERSION_ARG]: _dropped, ...modelArgs } = args; const vendorArgs = advertised[0]?.version diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 5ae6fa0c1..ca1e19775 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3686,6 +3686,15 @@ test("a Composio call sends the version recorded for that action", async () => { ]); }); +/* + * The composite end-to-end outcome: a model that supplies the reserved key itself does not change + * which revision runs. What holds it is the unconditional strip above the merge in `store.ts`. + * + * This is not a mutation gate, and no single mutation isolates it: reverting the strip, keeping the + * strip but falling back to the raw arguments, and reversing the spread all leave it green, and its + * assertion is already covered by `a Composio call sends the version recorded for that action`. It + * is kept because the property is one somebody will want to confirm, not because it guards it. + */ test("a version a model supplied in its own arguments cannot beat the recorded one", async () => { const { store, database } = await freshStore(); const calls: { slug: string; version: string }[] = []; @@ -3700,17 +3709,16 @@ test("a version a model supplied in its own arguments cannot beat the recorded o await store.callTool({ ref: "gmail/GMAIL_FETCH_EMAILS", - // A model filling in the reserved key itself. It reaches `args` intact — nothing strips a - // non-empty value — so the only thing standing between it and the vendor is that the recorded - // version is merged after it. + // A model filling in the reserved key itself. Stripped unconditionally, non-empty value and + // all, before the recorded version is merged — so it never reaches the vendor under either + // spread order. args: { __version: "19700101_00" }, botId: "bot_helper", actorId: "user_asker", }); - // The listed revision, not the one the model asked for. Reversed, this call would run against a - // revision that was never listed, never classified and never granted — and the audit row would - // name the action without naming which of its revisions actually ran. + // The listed revision, not the one the model asked for: supplying the reserved key changed + // nothing about which revision ran. expect(calls).toEqual([ { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, ]); @@ -3726,9 +3734,9 @@ test("a version a model supplied cannot stand in for an action with none recorde return {}; }, }); - // The action with no recorded version, which is the branch the test above does not cover: there is - // nothing to merge last, so the merge order guards nothing and the model's key is the only version - // in the arguments. + // The action with no recorded version, which is the branch the test above does not cover: there + // is nothing to merge in, and because the model's key was stripped there is no version in the + // arguments at all — which is what the transport refuses on. await seedComposioGmail(database, store, { version: null }); const result = await store.callTool({ @@ -3738,15 +3746,18 @@ test("a version a model supplied cannot stand in for an action with none recorde actorId: "user_asker", }); - // The transport's refusal, which is the advertised answer for an action with no recorded version — - // and an operator's one-click fix, rather than a call against a revision a model named. - expect(result.isError).toBe(true); - expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); - // Never dialled. Honoured, the model's version runs a granted action at a revision that was never // listed, never classified and never granted, and the audit row carries no version field to say // which revision that was. + // + // Asserted before the refusal, so a regression fails here and names the version that reached the + // vendor, rather than failing on a boolean that names nothing. expect(calls).toEqual([]); + + // The transport's refusal, which is the advertised answer for an action with no recorded version — + // and an operator's one-click fix, rather than a call against a revision a model named. + expect(result.isError).toBe(true); + expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); }); test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { From 1447a5513f25f389ca84544bc6716024cd6e4b1f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:03:27 +0200 Subject: [PATCH 35/93] Make the unlabelled-action canary able to fail Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-live.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts index fa499d87d..39d478d24 100644 --- a/server/tests/composio-live.test.ts +++ b/server/tests/composio-live.test.ts @@ -53,9 +53,9 @@ describe.skipIf(!live)("Composio, for real", () => { (action) => !(action.tags ?? []).some( (tag) => tag === "readOnlyHint" || tag === "destructiveHint", - ) && effectOf(action.tags).effect === "write", + ), ); - expect(unlabelled.length).toBeGreaterThanOrEqual(0); + expect(unlabelled).toEqual([]); const reads = actions.filter( (action) => effectOf(action.tags).effect === "read", From 72a84a5e55496b21566245eb3ed872ff72522f32 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:06:16 +0200 Subject: [PATCH 36/93] Resolve which Composio app a row is once, from the url the call dials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app was derived three ways — the row id in the connection gate, the url in the transport, and a column of its own in the schema — with nothing comparing them, so a row named `gmail` at `composio://slack` was checked against a Gmail connection and then run as Slack. It now comes off the url beside the rest of the descriptor, because the url is what the transport dials, and a brokered row without one is refused rather than falling back to the id. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 29 +++++++- server/src/plugins/store.ts | 28 ++++++- server/tests/composio-access.test.ts | 73 ++++++++++++++++--- server/tests/google-drive-rest.test.ts | 16 +++- server/tests/plugin-store.integration.test.ts | 38 +++++++++- 5 files changed, 165 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index da0edb302..15c339377 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -1,8 +1,10 @@ import type { CatalogueEntry } from "./catalogue"; +import { toolkitOf } from "./composio"; import type { TransportKind } from "./transport"; /** - * How one server row is reached: which protocol, whose credential, and whose name the trail records. + * How one server row is reached: which protocol, whose credential, which app at a broker, and whose + * name the trail records. * * WHY THIS EXISTS AS ONE THING. These three questions were asked separately, in three places, each * deriving its own answer from whichever field was nearest. That was complete while every server @@ -10,6 +12,12 @@ import type { TransportKind } from "./transport"; * is a row an operator enabled, with no entry to carry a transport field and no OAuth kind to read, * so all three questions answered wrongly by default and each failed silently in its own direction. * + * The fourth question arrived the same way. Which app a brokered row is was read from the row id by + * the gate that checks whether a person has connected it, from the url by the transport that dials + * it, and described as a third thing by the schema column that records the connection — with nothing + * comparing the three, so a row whose id and url slug differed was checked against one app and run + * against another. + * * Resolved once, here, and read as a field everywhere else. A fourth kind of server cannot be added * without filling in this function, and the test beside it enumerates every row shape that exists — * which is the exhaustiveness the previous arrangement could not offer, since nothing connects three @@ -43,6 +51,20 @@ export type ServerAccess = { * naming the asker would assert an attribution that does not exist. */ reachedAs: "person" | "deployment"; + /** + * Which app at the broker this row is, and null for a row that is not brokered at all. + * + * Read from the URL, because the URL is what the transport dials — so the app a person is checked + * against is the same app the call runs in, by construction rather than by two spellings agreeing. + * The row id is a display key: it is what an operator sees and what a grant names, and nothing + * keeps it equal to the slug in the URL. Deriving the app from it meant a row could pass the "has + * this person connected this app" gate on one spelling and run against another. + * + * Null everywhere else, because there is no app: an MCP endpoint and a per-person OAuth vendor are + * reached at an address, not at a broker, and a caller that finds null where it needs a toolkit is + * looking at a row it should not be brokering. + */ + toolkit: string | null; }; const CREDENTIAL_BY_AUTH: Record< @@ -91,7 +113,7 @@ const REACHED_AS_BY_AUTH: Record< * somebody else's MCP endpoint by definition, reached on the one token the deployment holds for it. */ export function accessFor( - row: { provenance: string }, + row: { provenance: string; url: string }, entry: CatalogueEntry | null, ): ServerAccess { if (entry) { @@ -99,6 +121,7 @@ export function accessFor( transport: entry.transport ?? "mcp", credential: CREDENTIAL_BY_AUTH[entry.auth.kind], reachedAs: REACHED_AS_BY_AUTH[entry.auth.kind], + toolkit: null, }; } @@ -107,6 +130,7 @@ export function accessFor( transport: "composio", credential: "brokered", reachedAs: "person", + toolkit: toolkitOf(row.url), }; } @@ -114,5 +138,6 @@ export function accessFor( transport: "mcp", credential: "deployment-token", reachedAs: "deployment", + toolkit: null, }; } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index edbec295e..66419d91e 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -805,12 +805,38 @@ export function createPluginStore(options: PluginStoreOptions) { ); } + /* + * Narrowing, not a second decision. + * + * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is non-null + * for every row whose credential resolved to `brokered` — but the descriptor declares it + * nullable because every other kind of row has no broker to name an app at, and the compiler + * cannot follow the credential back to the url it was derived beside. + * + * A throw rather than a fallback, for the reason the `user-oauth` narrowing below throws: both + * alternatives fail open. Falling back to `row.id` checks the connection against a spelling + * nothing dials, and skipping the gate spends the deployment's shared key on a connector whose + * whole purpose is to keep one person's account out of another's. + */ + if (!access.toolkit) { + throw new Error( + `${row.id} resolves to a brokered credential with no Composio app in its url.`, + ); + } + + /* + * Keyed on the app the call will run in, which is the one the url names. + * + * `row.id` is a display key and nothing holds it equal to the slug in the url, so a row named + * `gmail` at `composio://slack` passed this gate on a Gmail connection and then ran a Slack + * action — the person having connected an app they were never asked about. + */ const [connected] = await database .select({ toolkit: composioConnections.toolkit }) .from(composioConnections) .where( and( - eq(composioConnections.toolkit, row.id), + eq(composioConnections.toolkit, access.toolkit), eq(composioConnections.userId, actorId), ), ) diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index 8ff8e7c60..bcee2393d 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -20,18 +20,27 @@ import { catalogueEntry } from "../src/plugins/catalogue"; describe("accessFor", () => { test("a Composio app is dialled through Composio, brokered, and reached as the person", () => { // No entry, because an app an operator enabled is a row and not something we shipped. - expect(accessFor({ provenance: "composio" }, null)).toEqual({ + expect( + accessFor({ provenance: "composio", url: "composio://gmail" }, null), + ).toEqual({ transport: "composio", credential: "brokered", reachedAs: "person", + toolkit: "gmail", }); }); test("a server somebody added by URL is MCP, on the deployment's own token", () => { - expect(accessFor({ provenance: "custom" }, null)).toEqual({ + expect( + accessFor( + { provenance: "custom", url: "https://mcp.example.com/mcp" }, + null, + ), + ).toEqual({ transport: "mcp", credential: "deployment-token", reachedAs: "deployment", + toolkit: null, }); }); @@ -39,30 +48,44 @@ describe("accessFor", () => { const notion = catalogueEntry("notion"); expect(notion).not.toBeNull(); if (!notion) return; - expect(accessFor({ provenance: "first-party" }, notion)).toEqual({ + const notionUrl = `https://${notion.host}${notion.path}`; + expect( + accessFor({ provenance: "first-party", url: notionUrl }, notion), + ).toEqual({ transport: "mcp", credential: "person-oauth", reachedAs: "person", + toolkit: null, }); }); test("Drive is its REST adapter, on the asking person's own grant", () => { const drive = catalogueEntry("google-drive"); if (!drive) return; - expect(accessFor({ provenance: "first-party" }, drive)).toEqual({ + const driveUrl = `https://${drive.host}${drive.path}`; + expect( + accessFor({ provenance: "first-party", url: driveUrl }, drive), + ).toEqual({ transport: "google-drive-rest", credential: "person-oauth", reachedAs: "person", + toolkit: null, }); }); test("Routines is in-process, with no credential, and acts as the person", () => { const routines = catalogueEntry("routines"); if (!routines) return; - expect(accessFor({ provenance: "first-party" }, routines)).toEqual({ + expect( + accessFor( + { provenance: "first-party", url: "openbot://routines" }, + routines, + ), + ).toEqual({ transport: "builtin-routines", credential: "none", reachedAs: "person", + toolkit: null, }); }); @@ -81,20 +104,50 @@ describe("accessFor", () => { writeTools: [], docsUrl: "https://example.com/docs", }; - expect(accessFor({ provenance: "first-party" }, publicEntry)).toEqual({ + expect( + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + publicEntry, + ), + ).toEqual({ transport: "mcp", credential: "none", reachedAs: "deployment", + toolkit: null, }); }); test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { const notion = catalogueEntry("notion"); if (!notion) return; - // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one. - expect(accessFor({ provenance: "composio" }, notion).transport).toBe("mcp"); - expect(accessFor({ provenance: "composio" }, notion).credential).toBe( - "person-oauth", + // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one, + // and must not acquire an app at the broker either — a url edited to `composio://gmail` on a + // curated slug is the same tampering by another field. + const shadowed = accessFor( + { provenance: "composio", url: "composio://gmail" }, + notion, ); + expect(shadowed.transport).toBe("mcp"); + expect(shadowed.credential).toBe("person-oauth"); + expect(shadowed.toolkit).toBeNull(); + }); + + test("which app a Composio row is comes from its url, not from its id", () => { + // The id is a display key and the url is what the transport dials, so the url is what decides. + // A row named `gmail` at `composio://slack` used to be checked against a Gmail connection and + // then run as Slack, because three places derived this fact and none of them compared answers. + expect( + accessFor({ provenance: "composio", url: "composio://slack" }, null) + .toolkit, + ).toBe("slack"); + + // No app in the url is no app at all. `store.ts` refuses a brokered row that reaches it, rather + // than falling back to the id — see the narrowing throw beside its connection gate. + expect( + accessFor( + { provenance: "composio", url: "https://example.com/mcp" }, + null, + ).toolkit, + ).toBeNull(); }); }); diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index ac6ae0166..2700c5293 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -49,8 +49,12 @@ describe("the adapter is the transport the catalogue asks for", () => { expect(entry?.transport).toBe("google-drive-rest"); // Identity, not shape: proves the registry wired this module rather than something MCP-shaped. expect( - transportFor(accessFor({ provenance: "first-party" }, entry).transport) - .callTool, + transportFor( + accessFor( + { provenance: "first-party", url: "https://www.googleapis.com" }, + entry, + ).transport, + ).callTool, ).toBe(callTool); }); @@ -59,8 +63,12 @@ describe("the adapter is the transport the catalogue asks for", () => { // Composed through `accessFor`, which is where the absent-entry fallback now lives — and asserted // as MCP rather than as "not Drive", which any wrongly resolved kind would also satisfy. expect( - transportFor(accessFor({ provenance: "custom" }, null).transport) - .callTool, + transportFor( + accessFor( + { provenance: "custom", url: "https://mcp.example.com/mcp" }, + null, + ).transport, + ).callTool, ).toBe(mcpCallTool); }); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index ca1e19775..1a884074b 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3440,17 +3440,21 @@ async function freshStore() { * `version: null` is the action Composio listed without one — a granted, callable row whose version * column is null, which is a state the vendor's own optional field produces rather than a leftover * from before the column existed. + * + * `url` is an option because the id and the url are two fields and nothing holds them equal: a row + * called `gmail` at `composio://slack` is the shape that used to pass the connection gate on one + * spelling and run against the other. The connected person is still connected to `gmail`. */ async function seedComposioGmail( database: Database, store: PluginStore, - options: { connect?: boolean; version?: string | null } = {}, + options: { connect?: boolean; version?: string | null; url?: string } = {}, ) { await database.insert(mcpServers).values({ id: "gmail", title: "Gmail", vendor: "Composio", - url: "composio://gmail", + url: options.url ?? "composio://gmail", provenance: "composio", }); await database.insert(mcpTools).values({ @@ -3662,6 +3666,36 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +test("a Composio call whose row id and url name different apps is refused", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + // The row is called `gmail` and the person has connected `gmail`; the url dials Slack, which is + // the app the call would actually run in. + await seedComposioGmail(database, store, { url: "composio://slack" }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // The gate keys on the app the url names, because that is the app the transport dials. Keyed on + // the row id, this call completed: it ran a Slack action against a Gmail connection, sent the + // version recorded for the Gmail action, and was audited as having reached the asker's own + // account — a person granted one app and dialled into another with nothing noticing. + expect(reached).toEqual([]); +}); + test("a Composio call sends the version recorded for that action", async () => { const { store, database } = await freshStore(); const calls: { slug: string; version: string }[] = []; From 9162ae1169aca7f4df2b8684fc63a967e69f30c0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:07:21 +0200 Subject: [PATCH 37/93] Say which of the two brokered refusals the transport actually repeats The comment claimed a last line in the transport for both, and the transport has no notion of a connection at all: only the unattributed run is refused twice. It now names which case has the twin and why refusing an unconnected asker here still earns its place, since Composio's own error names an account it cannot find rather than the person's next step. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 66419d91e..20dd55cec 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -789,10 +789,17 @@ export function createPluginStore(options: PluginStoreOptions) { /* * A brokered app, where the deployment holds one key and Composio keeps the accounts apart. * - * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below is: - * a person gets a sentence naming the step they can take, and no call is spent at the vendor - * finding out. The transport refuses again as a last line, which is the belt to this braces — - * deleting either one has to turn a test red. + * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below + * is: a person gets a sentence naming the step they can take, and no call is spent at the + * vendor finding out. The transport refuses an unattributed run again as a last line, so + * deleting either that guard or this one has to turn a test red. The unconnected case has no + * such twin: the transport has no notion of a connection at all, so the last line there is + * Composio itself — which is what refusing locally earns its place for, since it turns the + * broker's error about an account it cannot find into a sentence naming the person's own next + * step. + * + * The throw between the two is not a third refusal. It is the narrowing that keeps this gate + * keyed on the app the url names, and its own comment says why neither fallback is available. * * There is no token. The key belongs to the transport and never travels through this function, so * nothing here can leak it into a connection object, an error or an audit row. From 112d884125b95604f7770a39a938bc6c1fb388c1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:16:20 +0200 Subject: [PATCH 38/93] Make the Notion listing mock restore what it overwrote, in one place Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 373 ++++++++---------- 1 file changed, 158 insertions(+), 215 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1a884074b..86fd6a221 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -8,7 +8,7 @@ import { test, } from "bun:test"; import { randomUUID } from "node:crypto"; -import { MCPMock } from "@copilotkit/aimock/mcp"; +import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; import { and, asc, eq, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; @@ -175,13 +175,15 @@ let ownsFixtureIds = false; * * The suites above own suite-scoped ids and only ever READ the deployment's own rows, so skipping a * delete is enough for them — that is what `suiteCreatedServerRow` and its siblings are for. - * The Composio fixtures at the bottom of this file cannot do that: they INSERT at `gmail`, `notion`, - * `bot_helper` and `user_asker`, and those ids are not a choice. `seedNotionServer` needs - * `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug - * that gets sent to Composio, so neither can be suffixed. A fixture that inserts at an id cannot - * coexist with a real row at that id: skipping the delete would only turn the collision into a - * primary-key conflict, and capture-and-restore would be a lot of machinery whose failure mode is - * destroying the thing it protects, because the cascade has already run by the time it restores. + * The fixtures in this file cannot do that: they INSERT at `gmail`, `notion`, `bot_helper` and + * `user_asker`, and those ids are not a choice. `gmail` is the toolkit slug that gets sent to + * Composio. `notion` is fixed twice over: the dynamic-registration suite pins `dynamicServerId` to + * it because that is the catalogue entry which registers its own client, and the test for an action + * listed before the effect columns existed inserts a `notion` server row directly, because a + * first-party row is what it is about. A fixture that inserts at an id cannot coexist with a real + * row at that id: skipping the delete would only turn the collision into a primary-key conflict, + * and capture-and-restore would be a lot of machinery whose failure mode is destroying the thing + * it protects, because the cascade has already run by the time it restores. * * What the cascade takes is why this is a refusal rather than a warning. `mcp_user_credentials` * references `mcp_servers.id`, so removing a real `notion` row takes every person's per-user @@ -1703,6 +1705,71 @@ describe("refresh token rotation", () => { }); }); +/** + * A real MCP server on localhost answering as the pinned Notion host, and everything a refresh + * against it overwrites put back afterwards. + * + * The seam is `fetch`: the host is pinned and nothing in the store will take a URL from a caller, so + * pointing the pinned host at the mock is what lets a real listing over the real protocol happen. + * What a refresh then overwrites is the deployment's own row — it replaces the advertised tool list + * wholesale and stamps `toolsRefreshedAt` and `lastError` — so the list and both stamps are read + * first and put back in a `finally`. + * + * A `finally` rather than a paragraph copied per test, because a restore is the part that a test + * still passes without: skip it and the cost lands on whatever runs next, reading a tool list this + * test invented. + */ +async function withMockedNotionListing( + notionServerId: string, + tools: MCPToolDefinition[], + body: () => Promise, +) { + const mock = new MCPMock(); + for (const tool of tools) mock.addTool(tool); + const mockUrl = await mock.start(); + + const advertisedBefore = await database + .select() + .from(mcpTools) + .where(eq(mcpTools.serverId, notionServerId)); + const [stampBefore] = await database + .select({ + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + lastError: mcpServers.lastError, + }) + .from(mcpServers) + .where(eq(mcpServers.id, notionServerId)); + + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const target = String(input instanceof Request ? input.url : input); + return realFetch( + target.startsWith("https://mcp.notion.com") ? mockUrl : input, + init, + ); + }) as typeof fetch; + + try { + await body(); + } finally { + globalThis.fetch = realFetch; + await mock.stop?.(); + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, notionServerId)); + if (advertisedBefore.length > 0) { + await database.insert(mcpTools).values(advertisedBefore); + } + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, + lastError: stampBefore?.lastError ?? null, + }) + .where(eq(mcpServers.id, notionServerId)); + } +} + /** * A client this deployment registered for itself, which the vendor has since forgotten. * @@ -2524,9 +2591,8 @@ describe("a dynamic client the vendor has evicted", () => { * silent. * * The vendor here is a real MCP server on localhost, reached by pointing the pinned host at it for - * the length of this test. The host is pinned for good reasons and nothing in the store will take a - * URL from a caller, so the seam is fetch — which is also the honest one: what is under test is - * what a real listing over the real protocol produces. + * the length of this test — see {@link withMockedNotionListing}, which also puts back what the + * refresh overwrites. What is under test is what a real listing over the real protocol produces. */ test("a refresh names the advertised tools no write list covers", async () => { await putClient(EVICTED); @@ -2535,65 +2601,30 @@ describe("a dynamic client the vendor has evicted", () => { /** Suite-scoped, so it cannot be a name Notion really advertises, nor a name in `writeTools`. */ const unlistedName = `notion-invent-${suite}`; - const mock = new MCPMock(); - mock - .addTool({ - name: "notion-create-pages", - description: "A write the list already names.", - inputSchema: { type: "object", properties: {} }, - }) - .addTool({ - name: unlistedName, - description: "Advertised, and named by no write list.", - inputSchema: { type: "object", properties: {} }, - }); - const mockUrl = await mock.start(); - - // What the deployment currently advertises for this server, because a refresh replaces the list - // wholesale and this one is pointing the vendor at a mock. - const advertisedBefore = await database - .select() - .from(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - const [stampBefore] = await database - .select({ - toolsRefreshedAt: mcpServers.toolsRefreshedAt, - lastError: mcpServers.lastError, - }) - .from(mcpServers) - .where(eq(mcpServers.id, dynamicServerId)); - - const realFetch = globalThis.fetch; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const target = String(input instanceof Request ? input.url : input); - return realFetch( - target.startsWith("https://mcp.notion.com") ? mockUrl : input, - init, - ); - }) as typeof fetch; - try { - expect( - await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), - ).toEqual({ tools: 2 }); - } finally { - globalThis.fetch = realFetch; - await mock.stop?.(); - await database - .delete(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - if (advertisedBefore.length > 0) { - await database.insert(mcpTools).values(advertisedBefore); - } - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, - lastError: stampBefore?.lastError ?? null, - }) - .where(eq(mcpServers.id, dynamicServerId)); - } + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: unlistedName, + description: "Advertised, and named by no write list.", + inputSchema: { type: "object", properties: {} }, + }, + ], + async () => { + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + }, + ); + // Read after the restore, because the audit trail is what the refresh leaves that the restore + // does not take back. const named = ( await database .select({ payload: auditEvents.payload }) @@ -2631,105 +2662,70 @@ describe("a dynamic client the vendor has evicted", () => { await connect(); accepted = new Set([EVICTED.clientId]); - const mock = new MCPMock(); - mock - .addTool({ - name: "notion-fetch", - description: "A read no write list names.", - inputSchema: { type: "object", properties: {} }, - }) - .addTool({ - name: "notion-create-pages", - description: "A write the list already names.", - inputSchema: { type: "object", properties: {} }, - }); - const mockUrl = await mock.start(); - - // What the deployment currently advertises for this server, because a refresh replaces the list - // wholesale and this one is pointing the vendor at a mock. - const advertisedBefore = await database - .select() - .from(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - const [stampBefore] = await database - .select({ - toolsRefreshedAt: mcpServers.toolsRefreshedAt, - lastError: mcpServers.lastError, - }) - .from(mcpServers) - .where(eq(mcpServers.id, dynamicServerId)); - - const realFetch = globalThis.fetch; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const target = String(input instanceof Request ? input.url : input); - return realFetch( - target.startsWith("https://mcp.notion.com") ? mockUrl : input, - init, - ); - }) as typeof fetch; - - try { - // Two tools listed, so the assertions below have something to be about: `every` over an empty - // list is true, and a refusal recorded in `lastError` would leave exactly that. - expect( - await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), - ).toEqual({ tools: 2 }); - - const rows = await database - .select({ - name: mcpTools.name, - effect: mcpTools.effect, - destructive: mcpTools.destructive, - version: mcpTools.version, - }) - .from(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)) - .orderBy(asc(mcpTools.name)); - - expect(rows).toEqual([ + await withMockedNotionListing( + dynamicServerId, + [ { - name: "notion-create-pages", - effect: null, - destructive: false, - version: null, + name: "notion-fetch", + description: "A read no write list names.", + inputSchema: { type: "object", properties: {} }, }, { - name: "notion-fetch", - effect: null, - destructive: false, - version: null, + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, }, - ]); - - const listed = (await dynamicStore.listServers()).find( - (server) => server.id === dynamicServerId, - ); + ], + async () => { + // Two tools listed, so the assertions below have something to be about: `every` over an + // empty list is true, and a refusal recorded in `lastError` would leave exactly that. + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "notion-create-pages", + effect: null, + destructive: false, + version: null, + }, + { + name: "notion-fetch", + effect: null, + destructive: false, + version: null, + }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); - // The reviewed write list, still deciding: the name it covers is a write and the name it does - // not is a read. A recorded effect on either row is what would take this over. - expect( - listed?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), - ).toEqual([ - { name: "notion-create-pages", effect: "write" }, - { name: "notion-fetch", effect: "read" }, - ]); - } finally { - globalThis.fetch = realFetch; - await mock.stop?.(); - await database - .delete(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - if (advertisedBefore.length > 0) { - await database.insert(mcpTools).values(advertisedBefore); - } - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, - lastError: stampBefore?.lastError ?? null, - }) - .where(eq(mcpServers.id, dynamicServerId)); - } + // The reviewed write list, still deciding: the name it covers is a write and the name it + // does not is a read. A recorded effect on either row is what would take this over. + expect( + listed?.tools.map((tool) => ({ + name: tool.name, + effect: tool.effect, + })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + }, + ); }); /** @@ -3364,9 +3360,6 @@ describe("a vendor reply that is not a token", () => { * ids exists, which makes every row they remove one of this file's own. Cleaning before each test * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; * the `afterAll` below is what stops the last test's fixtures from outliving the run. - * - * {@link seedNotionServer} below is `export`ed for one reason: it is shared scaffolding whose first - * caller has not been written yet, and a fixture nothing calls reads to the linter as dead code. */ async function freshDatabase(): Promise { /* @@ -3483,60 +3476,10 @@ async function seedComposioGmail( ); } -/** Whatever a fixture installed into a global, undone after the test that installed it. */ -const installed: (() => Promise)[] = []; - -/** - * A first-party MCP server, with a real MCP server on localhost standing in for the vendor. - * - * The seam is `fetch`, the way the refresh test above does it: the host is pinned and nothing in the - * store will take a URL from a caller, so pointing the pinned host at the mock is what lets a real - * listing over the real protocol happen. `store` is taken and unused because this vendor needs no - * grant to be listed — the callers hand it over so the two seeds read the same way. - */ -export async function seedNotionServer( - database: Database, - _store: PluginStore, -) { - await database.insert(mcpServers).values({ - id: "notion", - title: "Notion", - vendor: "Notion", - url: "https://mcp.notion.com/mcp", - provenance: "first-party", - }); - - const mock = new MCPMock(); - mock.addTool({ - name: "notion-fetch", - description: "Fetch a page.", - inputSchema: { type: "object", properties: {} }, - }); - const mockUrl = await mock.start(); - - const realFetch = globalThis.fetch; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const target = String(input instanceof Request ? input.url : input); - return realFetch( - target.startsWith("https://mcp.notion.com") ? mockUrl : input, - init, - ); - }) as typeof fetch; - - installed.push(async () => { - globalThis.fetch = realFetch; - await mock.stop?.(); - }); -} - // The vendor is a process-wide registry, so a stub outliving its test would be answering somebody // else's calls. afterEach(() => useComposioClient(null)); -afterEach(async () => { - while (installed.length > 0) await installed.pop()?.(); -}); - /* * The last test's fixtures, which nothing else would remove. * From 29d91ab5b9533b2be848cdee5141db1654debd17 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:19:02 +0200 Subject: [PATCH 39/93] Gate the two brokered properties the plan named and nothing covered Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 86fd6a221..901cc4b68 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3583,6 +3583,56 @@ test("a Composio call with nobody attributed is refused before it reaches the ve expect(reached).toEqual([]); }); +/** + * The same refusal, with a legal row sitting at the anonymous actor. + * + * `composio_connections.user_id` is text notNull with NO foreign key — the property that lets a + * connection outlive its person, so offboarding can still find it and revoke it at the broker. And + * `notNull` does not exclude the empty string, so a row at `("gmail", "")` is legal: without the + * guard ahead of the lookup, that row IS the match, the connection gate passes, and the run goes + * out in whatever account Composio holds against "". The sibling `user-oauth` path cannot reach + * this state — `mcp_user_credentials.user_id` carries a foreign key to `users.id` — so its test + * asserts only the sentence, and borrowing that shape here would leave this property untested. + * + * A rejection, not a failed result, and that is the assertion doing the work: the transport repeats + * the refusal as its own last line, but it answers with `isError` rather than throwing. So a + * `rejects` here is what separates this gate from its twin downstream of the lookup. + */ +test("a Composio call with nobody attributed is refused even when a connection row exists for the empty actor", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + await seedComposioGmail(database, store); + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "" }); + + try { + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "", + }), + ).rejects.toThrow(/not attributed to anybody/i); + + expect(reached).toEqual([]); + } finally { + // Inline, because `freshDatabase` clears this table by the two people this file invents and the + // anonymous actor is neither — so nothing else in the run would ever remove this row. + await database + .delete(composioConnections) + .where(eq(composioConnections.userId, "")); + } +}); + test("a Composio call by somebody who has not connected the app is refused with a sentence they can act on", async () => { const { store, database } = await freshStore(); const reached: string[] = []; @@ -3977,3 +4027,53 @@ test("refreshing a Composio app records each action's effect, destructive marker }, ]); }); + +/** + * A grant on a Composio action the vendor has stopped listing, still reported as held. + * + * Confirmed for a brokered row rather than built: `listServers` derives `withdrawn` from the grants + * no advertised action covers, with no transport-specific branch, so the Drive suite above gates + * the mechanism. What a Composio row adds is that it inherits it — the app's page lists what the + * last refresh advertised, so with nothing reporting the gap a permission on an action Composio + * dropped is invisible and the Bot loses a capability nobody revoked. + * + * The refreshed listing names a DIFFERENT action rather than none at all, because an empty list is + * also what a refresh the vendor refused leaves behind: every grant would come out withdrawn and + * this would hold for a reason that has nothing to do with the action being gone. + */ +test("a granted Composio action that the vendor withdrew is still shown as granted", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => ({}), + }); + await seedComposioGmail(database, store); + + // The granted action is absent from what the vendor now lists, and another action is not. + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + expect(gmail?.withdrawn).toEqual([ + { + ref: "gmail/GMAIL_FETCH_EMAILS", + name: "GMAIL_FETCH_EMAILS", + grantedTo: ["bot_helper"], + }, + ]); + // The advertised action came through as a tool, which is what says the refresh actually listed + // rather than failing into the empty list that would withdraw everything. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_SEND_EMAIL", + ]); +}); From abca7a7398a10693d6f692d06c48a6db1b0f4800 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:40:14 +0200 Subject: [PATCH 40/93] Say that the brokered narrowing throw is reachable, and gate the fallback it forbids Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 25 ++++++++++------- server/tests/plugin-store.integration.test.ts | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 20dd55cec..fca3fd8da 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -764,7 +764,9 @@ export function createPluginStore(options: PluginStoreOptions) { * object, an error or an audit row. What this function contributes instead is the two refusals * that have to happen before a call is spent at the broker: a run nobody is attributed for, and an * asker who has not connected the app — so a person is told their own next step rather than shown - * the broker's error about an account it cannot find. + * the broker's error about an account it cannot find. A third refusal sits between those two, for + * a brokered row whose url names no Composio app; nothing in the product creates such a row, so no + * person's situation reaches it. * * For a `person-oauth` server it is the asker's own, and every branch that cannot prove it has the * asker's grant refuses. There is deliberately no fallback. A fallback is the one bug this design @@ -798,8 +800,10 @@ export function createPluginStore(options: PluginStoreOptions) { * broker's error about an account it cannot find into a sentence naming the person's own next * step. * - * The throw between the two is not a third refusal. It is the narrowing that keeps this gate - * keyed on the app the url names, and its own comment says why neither fallback is available. + * The throw between the two is a third refusal, but not one anybody can act on: it fires only + * for a brokered row whose url names no Composio app, which nothing in the product can create. + * It is what keeps this gate keyed on the app the url names, and its own comment says why + * neither fallback is available. * * There is no token. The key belongs to the transport and never travels through this function, so * nothing here can leak it into a connection object, an error or an audit row. @@ -813,17 +817,20 @@ export function createPluginStore(options: PluginStoreOptions) { } /* - * Narrowing, not a second decision. + * Narrowing, and a refusal that is genuinely reachable. * - * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is non-null - * for every row whose credential resolved to `brokered` — but the descriptor declares it - * nullable because every other kind of row has no broker to name an app at, and the compiler - * cannot follow the credential back to the url it was derived beside. + * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is NULL + * whenever that url does not name a Composio app — `accessFor` still answers `brokered` for + * any row whose provenance column says composio, so `{ credential: "brokered", toolkit: null }` + * is a state a hand-edited or restored row really produces. The test beside `accessFor` + * asserts it, and `plugin-store.integration.test.ts` gates this branch end to end. * * A throw rather than a fallback, for the reason the `user-oauth` narrowing below throws: both * alternatives fail open. Falling back to `row.id` checks the connection against a spelling * nothing dials, and skipping the gate spends the deployment's shared key on a connector whose - * whole purpose is to keep one person's account out of another's. + * whole purpose is to keep one person's account out of another's. The compiler forces SOME + * narrowing here — drizzle's `eq` will not take `string | null` — but only the test named + * above stops that narrowing from being the fallback. */ if (!access.toolkit) { throw new Error( diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 901cc4b68..f4c5778d4 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3659,6 +3659,33 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +test("a Composio call whose url names no app is refused rather than falling back to the row id", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + // Brokered by provenance, with a url that names no Composio app: `accessFor` answers + // `{ credential: "brokered", toolkit: null }`, and falling back to the row id would check a + // Gmail connection and then dial a hostname. + await seedComposioGmail(database, store, { url: "https://example.com/mcp" }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/no Composio app in its url/i); + + expect(reached).toEqual([]); +}); + test("a Composio call whose row id and url name different apps is refused", async () => { const { store, database } = await freshStore(); const reached: string[] = []; From 7c9ea0fc52c2e9da74c2710c07a4d68213b4c572 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:40:14 +0200 Subject: [PATCH 41/93] Build the catalogue urls in the access tests from hosts that already carry a scheme Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-access.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index bcee2393d..be9dcdebf 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -48,7 +48,7 @@ describe("accessFor", () => { const notion = catalogueEntry("notion"); expect(notion).not.toBeNull(); if (!notion) return; - const notionUrl = `https://${notion.host}${notion.path}`; + const notionUrl = `${notion.host}${notion.path}`; expect( accessFor({ provenance: "first-party", url: notionUrl }, notion), ).toEqual({ @@ -62,7 +62,7 @@ describe("accessFor", () => { test("Drive is its REST adapter, on the asking person's own grant", () => { const drive = catalogueEntry("google-drive"); if (!drive) return; - const driveUrl = `https://${drive.host}${drive.path}`; + const driveUrl = `${drive.host}${drive.path}`; expect( accessFor({ provenance: "first-party", url: driveUrl }, drive), ).toEqual({ From b6f7b0ce8a780cfbe52ca423c19306a46d88314b Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:37:47 +0200 Subject: [PATCH 42/93] Stop a value in a vendor column taking an action off the reviewed write list classifyTool consulted the recorded effect before entry.writeTools, so a recorded `read` overrode a hand-reviewed write: notion-update-page classified as a read. The recorded effect is plain `text` with no check constraint, and `writeTools` is what a person read before it shipped, so the order is now advertised, then the reviewed list, then the column. A recorded effect may narrow what a Bot may do and may never widen it. `if (recorded)` also read the empty string as "nothing was recorded", so an advertised action absent from the write list fell through to a read. Presence rather than truthiness decides now, and only the exact string `read` earns a read. Three comments claimed the old behaviour and now say what the code does. The pattern case in plugin-catalogue.test.ts borrowed a real catalogue key, so it passed only because no entry declares a hostPattern today; it uses a key no entry holds and asserts that. The recorded-write case returned early on a null entry without asserting it was not null. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/catalogue.ts | 80 ++++++++++++------ server/tests/composio-classify.test.ts | 112 +++++++++++++++++++++++-- server/tests/plugin-catalogue.test.ts | 44 ++++++++-- 3 files changed, 200 insertions(+), 36 deletions(-) diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index 4205fd959..c3a91fdbc 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -116,8 +116,13 @@ export type CatalogueEntry = { * unknown tool as a write rather than as a read: a tool the server never advertised, so nothing * here could have named it, is safe to over-scrutinize as a write. The opposite direction is the * one that matters for this list: a tool the server DOES advertise but that is missing from here - * classifies as a read, so an incomplete list is the failure mode, not a safe default — this list - * has to lean over-inclusive. + * classifies as a read unless the vendor itself recorded an effect for it, so for a vendor that + * records nothing — which is every one here today — an incomplete list is the failure mode rather + * than a safe default, and this list has to lean over-inclusive. + * + * Nothing outside review can shorten it, either. A name this list holds is a write no matter what + * a vendor recorded for that action, because this list is what a person read and the recorded + * effect is whatever was last written into an unconstrained column. */ writeTools: readonly string[]; /** @@ -244,9 +249,11 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ * The writing tools as the hosted server advertises them today. The hosted server advertises * its tools, so a name here that does not match an advertised tool is not the risk — an * advertised tool that is missing from this list is: {@link classifyTool} reads an unlisted - * but advertised name as a read, never as a write. That makes under-inclusion the failure - * mode, so this list has to lean over-inclusive rather than minimal, and reconciling it - * against the live tool list on the first Refresh tools is required, not cosmetic. + * but advertised name as a read, never as a write. Notion's MCP listing carries no per-action + * effect, so this list is the only thing that can say otherwise and nothing backstops it. That + * makes under-inclusion the failure mode, so this list has to lean over-inclusive rather than + * minimal, and reconciling it against the live tool list on the first Refresh tools is + * required, not cosmetic. */ writeTools: Object.freeze([ "notion-convert-page-to-skill", @@ -361,20 +368,29 @@ export function resolveServerUrl( /** * What this tool does, in the only two categories a policy author cares about. * - * TWO SOURCES, CONSULTED IN ORDER. What the vendor recorded about this action when it was listed - * comes first: exactly `read` is a read, and anything else — a recorded write, an unrecognised - * value, a different case — is a write. Only where nothing was recorded does the reviewed write list - * decide, and there a tool the entry names is a write while one it does not name is a read. + * A RECORDED EFFECT MAY NARROW WHAT A BOT MAY DO AND MAY NEVER WIDEN IT. That is the criterion the + * order below is built from, and it is why the reviewed write list is consulted FIRST. Both sources + * are trusted to make an action a write; only the reviewed one is trusted to make an action a read + * where the other says write. `writeTools` was read by a person before it shipped. A recorded effect + * arrives from a vendor listing into a plain `text` column with no check constraint, so it is + * whatever was last written there, by a refresh or by a hand on a psql prompt. A value in that column + * therefore cannot take an action off the reviewed list. + * + * THREE SOURCES, CONSULTED IN THIS ORDER. Whether the server advertised the name at all: it did not, + * the name came from a model and the answer is write, and nothing later overrides that. Then the + * reviewed write list, which settles a name it holds as a write. Then what the vendor recorded, where + * exactly `read` is a read and every other value it holds — a recorded write, an unrecognised label, + * a different case, the empty string — is a write. Where the column holds nothing at all the reviewed + * list finishes the job: an advertised name it declines to call a write is a read. * - * Unknown counts as a write throughout. A tool the server never advertised at all is a write, - * because the only thing that produced the name was a model. A server with no catalogue entry behind - * it is a write unless the vendor recorded a read for that action — there is no reviewed list to - * consult, so an unlabelled action of theirs has nothing saying it is safe. + * Unknown counts as a write throughout. A server with no catalogue entry behind it is a write unless + * the vendor recorded a read for that action — there is no reviewed list to consult, so an unlabelled + * action of theirs has nothing saying it is safe. * * So there are two ways to earn a read, and both require somebody to have said so. Either the vendor - * labelled the action a read, or the server advertised it and a reviewed list declined to call it a - * write. Guessing permissively is recoverable only in those two cases; everywhere else the answer is - * a write. + * labelled the action a read and no reviewed list contradicts them, or the server advertised it and a + * reviewed list declined to call it a write. Guessing permissively is recoverable only in those two + * cases; everywhere else the answer is a write. */ export function classifyTool( entry: CatalogueEntry | null, @@ -383,25 +399,37 @@ export function classifyTool( /** * What the vendor said about this action when it was listed, or null when nothing did. * - * Consulted BEFORE the entry's write list, because it is the better source and the only one that - * can exist for a broker's catalogue: Composio labels every one of Gmail's sixty-three actions, and - * no reviewed list here could keep pace with several hundred apps that change weekly. + * Consulted AFTER the entry's write list, so it can only agree with review or add to it. It is the + * only source that can exist for a broker's catalogue — Composio labels every one of Gmail's + * sixty-three actions, and no reviewed list here could keep pace with several hundred apps that + * change weekly — so where review said nothing it is the whole answer. * - * Only the exact string `read` produces a read. A recorded write, an unrecognised value, a - * different case, an empty string and null are all writes, so a column somebody typed into by hand, - * or a label a vendor adds later that this code has never heard of, cannot widen what a Bot may do - * unasked. + * PRESENCE, NOT TRUTHINESS, is what makes it consulted, and only the exact string `read` produces a + * read. A recorded write, an unrecognised value, a different case and the EMPTY STRING are all + * writes: the empty string is a value the column holds rather than a silence, and reading it as + * "nothing was recorded" would send an advertised action no reviewed list names down the read + * branch. Null and undefined are the column saying nothing, and fall through to the reviewed list. + * So a column somebody typed into by hand, or a label a vendor adds later that this code has never + * heard of, cannot widen what a Bot may do unasked. */ recorded?: string | null, ): "read" | "write" { // A name the server never listed came from a model, and nothing reviewed says it only reads — - // checked first, so a recorded effect cannot rescue a name that was never advertised. + // checked first, so neither later source can rescue a name that was never advertised. if (!advertised) return "write"; - if (recorded) return recorded === "read" ? "read" : "write"; + // The reviewed list outranks the column, and only in this direction: a name a person reviewed as a + // write stays a write whatever the listing recorded about it. + if (entry?.writeTools.includes(toolName)) return "write"; + // Anything the column holds settles the rest. `typeof` rather than truthiness so the empty string + // is treated as the value it is instead of as an absence of one. + if (typeof recorded === "string") + return recorded === "read" ? "read" : "write"; // A server an administrator added by URL has no reviewed tool catalogue behind it, so nothing here // can say a tool of theirs only reads. Everything it offers is a write. if (!entry) return "write"; - return entry.writeTools.includes(toolName) ? "write" : "read"; + // Advertised, not on the reviewed write list, and nothing recorded. Two sources had the chance to + // call it a write and neither did. + return "read"; } /** diff --git a/server/tests/composio-classify.test.ts b/server/tests/composio-classify.test.ts index 28d08f7ae..a0d6ab9a4 100644 --- a/server/tests/composio-classify.test.ts +++ b/server/tests/composio-classify.test.ts @@ -1,13 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { classifyTool } from "../src/plugins/catalogue"; +import { catalogueEntry, classifyTool } from "../src/plugins/catalogue"; /** * What an action does, when the vendor said so and when nobody did. * - * The property under test is the direction of the failure. A recorded `read` is the only input that - * can produce a read; everything else — a recorded write, an unrecognised value, null, an empty - * string — is a write. That asymmetry is the point: an action wrongly gated as a write costs a - * confirmation, and one wrongly waved through as a read costs somebody's mailbox. + * The property under test is the direction of the failure. Exactly two things can earn a read, and + * both require somebody to have said so: the vendor recorded exactly `read`, or a curated entry + * advertised the action and a reviewed list declined to call it a write. Everything else — a + * recorded write, an unrecognised value, a different case, an empty string, a name no server + * advertised, a server nobody reviewed — is a write. That asymmetry is the point: an action wrongly + * gated as a write costs a confirmation, and one wrongly waved through as a read costs somebody's + * mailbox. + * + * THE RECORDED VALUE MAY NARROW WHAT A BOT MAY DO AND MAY NEVER WIDEN IT. It arrives from a vendor + * listing into a plain `text` column with no constraint on its contents, so it outranks nothing that + * a human reviewed. Both halves are pinned below: a recorded write settles an action the curated + * list forgot, and a recorded read cannot unsettle one the curated list named. + * + * The first block passes a null entry throughout, which is the brokered shape — a Composio app has + * no curated entry behind it. The second block passes a real one, because a null entry is itself a + * blanket write and would let the precedence cases below pass without exercising the precedence. */ describe("classifyTool with a recorded effect", () => { test("a recorded read is a read", () => { @@ -42,3 +54,93 @@ describe("classifyTool with a recorded effect", () => { expect(classifyTool(null, "GMAIL_INVENTED", false, "read")).toBe("write"); }); }); + +describe("a recorded effect against a curated entry", () => { + const notion = catalogueEntry("notion"); + + /* + * The two names every case below is built on, asserted once. + * + * `notion-update-page` has to be ON the write list and `notion-fetch` has to be OFF it, or the + * expectations stop meaning what they say: a reviewed write whose name drifted off the list would + * turn the precedence cases into ordinary unlisted-tool cases and they would keep passing. + */ + test("the entry these cases are about names one of them a write and not the other", () => { + expect(notion).not.toBeNull(); + expect(notion?.writeTools).toContain("notion-update-page"); + expect(notion?.writeTools).not.toContain("notion-fetch"); + }); + + test("a recorded read cannot override a curated entry's write list", () => { + /* + * THE CASE THIS WHOLE BLOCK EXISTS FOR. `effect` is vendor-supplied text in a column with no + * check constraint and no product writer other than the refresh path, so a `read` in it is + * reachable by a hand edit or a restore. `writeTools` was reviewed by a person. Letting the + * column win here would buy an action LESS scrutiny than review already gave it, which is the + * one direction this classifier must never move in. + */ + expect(classifyTool(notion, "notion-update-page", true, "read")).toBe( + "write", + ); + expect(classifyTool(notion, "notion-create-pages", true, "read")).toBe( + "write", + ); + expect(classifyTool(notion, "notion-move-pages", true, "read")).toBe( + "write", + ); + }); + + test("a recorded read still settles an action the write list does not name", () => { + // The permitted direction, and the reason the column is consulted at all: where review said + // nothing, the vendor's own label is the better source and is taken at its word. + expect(classifyTool(notion, "notion-fetch", true, "read")).toBe("read"); + }); + + test("a recorded write settles an action the write list forgot", () => { + // The other permitted direction. The write list is known-incomplete, so a vendor saying an + // action writes narrows what a Bot may do and is honoured. + expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); + }); + + test("an empty recorded effect is a write, not an absence of opinion", () => { + /* + * A `text` column holding the empty string is a value, not a null. Reading it as "nothing was + * recorded" sends an advertised action that no reviewed list names down the read branch, which + * is the widening this classifier exists to refuse. Both sides of the write list are pinned so + * the answer cannot depend on which one the name falls on. + */ + expect(classifyTool(notion, "notion-fetch", true, "")).toBe("write"); + expect(classifyTool(notion, "notion-update-page", true, "")).toBe("write"); + }); + + test("a recorded value nothing recognises is a write", () => { + // A label a vendor invents later, a typo, or the wrong case. None of them is `read`, so none of + // them earns a read, whether or not the reviewed list names the action. + for (const value of ["readonly", "READ", "Read", "destructive", "none"]) { + expect(classifyTool(notion, "notion-fetch", true, value)).toBe("write"); + expect(classifyTool(notion, "notion-update-page", true, value)).toBe( + "write", + ); + } + }); + + test("nothing recorded leaves the curated write list deciding", () => { + // The behaviour that shipped before the column existed, unchanged for every row written before + // it. Null and undefined are the column saying nothing, which is not a value. + for (const value of [null, undefined]) { + expect(classifyTool(notion, "notion-update-page", true, value)).toBe( + "write", + ); + expect(classifyTool(notion, "notion-fetch", true, value)).toBe("read"); + } + }); + + test("a recorded read cannot rescue a name the entry's server never advertised", () => { + // Checked before either source, so the model-invented name is refused whatever the column says + // and whatever the reviewed list says. + expect(classifyTool(notion, "notion-fetch", false, "read")).toBe("write"); + expect(classifyTool(notion, "notion-invented", false, "read")).toBe( + "write", + ); + }); +}); diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index fc3f39d48..7aa8c49f7 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -44,15 +44,23 @@ describe("which servers this deployment will talk to", () => { /* * WHAT THIS NO LONGER COVERS. ServiceNow was the only per-instance entry, and removing it took * the anchored-pattern assertions with it — that a prefix, a suffix and a subdomain are each - * refused. `PATTERNS` is compiled from the catalogue by key, so a synthetic entry cannot reach a - * pattern and there is no way left to exercise the matching itself through the public API. + * refused. `PATTERNS` is compiled from the catalogue by KEY, so an entry this build never + * compiled a pattern for reaches no pattern, and there is no way left to exercise the matching + * itself through the public API. * * What survives is the fail-closed half, which is worth keeping on its own: an entry claiming to * be per-instance that this build has no pattern for is refused rather than admitted. Whoever * adds the next per-instance vendor should restore the anchoring cases with it. + * + * THE KEY IS DELIBERATELY ONE NO ENTRY HOLDS, and asserted to be. This case used to borrow + * `google-drive`, which passed only because no catalogue entry declares a `hostPattern` today: + * `PATTERNS` is keyed, not identity-checked, so a synthetic entry that reuses a real key reaches + * whatever pattern that key compiled. Giving Drive a pattern made the old version admit + * `https://acme.service-now.com`. With an unheld key the lookup misses for the reason the test + * names, whatever the catalogue later declares. */ const perInstance = { - key: "google-drive", + key: "no-entry-holds-this-key", title: "Per-instance vendor", vendor: "Example", summary: "", @@ -65,8 +73,9 @@ describe("which servers this deployment will talk to", () => { docsUrl: "", } as const; - // `PATTERNS` is compiled from the catalogue by key, so a synthetic entry reaches no pattern and - // is refused outright. That is itself the fail-closed property: no pattern means no. + expect(catalogueEntry(perInstance.key)).toBeNull(); + // No compiled pattern for this key, so the entry's own `hostPattern` is never consulted and the + // host is refused outright. That is itself the fail-closed property: no pattern means no. expect(hostAdmissible(perInstance, "https://acme.service-now.com")).toBe( false, ); @@ -556,9 +565,34 @@ test("a curated entry keeps classifying from its write list when nothing was rec test("a recorded write overrides a curated entry that omits the action", () => { const notion = catalogueEntry("notion"); + // Asserted rather than only guarded. The early return below reads as a pass, so a renamed or + // dropped key would retire this case silently instead of failing. + expect(notion).not.toBeNull(); if (!notion) return; // The write list is known-incomplete. A vendor saying an action writes settles it, and the list // being out of date stops mattering. expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); }); + +test("a recorded read cannot take an action off a curated entry's write list", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + + /* + * The mirror image of the case above, and the only direction that is refused. A recorded effect + * may NARROW what a Bot may do — the `write` case above — and may never widen it: `effect` is + * vendor-supplied text in an unconstrained column, `writeTools` was reviewed by a person, and a + * value in the column must not buy an action less scrutiny than review already gave it. The + * per-value cases live in composio-classify.test.ts; this pins the property beside the write list + * it protects, so dropping a name from that list fails here too. + */ + expect(notion.writeTools).toContain("notion-update-page"); + expect(classifyTool(notion, "notion-update-page", true, "read")).toBe( + "write", + ); + // And the empty string is a recorded value rather than a silence, so it does not fall through to + // the write list and read as a read for an action the list omits. + expect(classifyTool(notion, "notion-fetch", true, "")).toBe("write"); +}); From 4e9e9c0b53bd8e53793e76e0722c408db46246c6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:37:32 +0200 Subject: [PATCH 43/93] Say where the actor actually authorizes a transport call, which is not the listing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/transport.ts | 65 +++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 469105d0b..cead6af40 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -12,23 +12,38 @@ import * as mcp from "./mcp"; * API is generally available — so one vendor needed a second way in, and a second way in wants a * seam rather than a branch at each call site. * - * The interface is MCP's OWN, unchanged: `listTools` and `callTool`, the two functions - * {@link ./mcp} already exported, with the shapes it already used. That direction matters. Had the - * REST adapter been given its own interface with MCP adapted to fit, MCP would have become a special - * case of a shape invented for Drive. As it is, MCP is the contract and the adapter conforms to it, - * which is why swapping back is one field on one entry and not a refactor. + * The interface STARTED as MCP's OWN: `listTools` and `callTool`, the two functions {@link ./mcp} + * already exported, with the shapes it already used. That direction matters. Had the REST adapter + * been given its own interface with MCP adapted to fit, MCP would have become a special case of a + * shape invented for Drive. As it is, MCP is the contract the adapters conform to, which is why + * swapping Drive back is one field on one catalogue entry and not a refactor. + * + * What has been added since is a SUPERSET of that shape rather than a departure from it, so an + * MCP-shaped implementation still satisfies the seam unchanged. `listTools` answers `ListedTool[]`, + * which is `McpTool` plus optional fields a broker publishes and an MCP server does not; the + * connection carries an `actorId` and a `botId` for the transports whose authorization is the + * actor rather than a credential; and one reserved key on `args` hands a transport the recorded + * version of the action being called. Every addition is optional, which is why `mcp.ts` reads none + * of them and is still an implementation of this interface rather than an exception to it. * * There are exactly two call sites in the whole system — the tool listing and the tool call — and - * both take a transport from here. Nothing else, including the OAuth flow, the per-person credential - * selection, the grants, the policy engine and the audit trail, knows which protocol is underneath. + * both take a transport from here. Nothing else reads a `TransportKind` at all: the OAuth flow, + * the grants, the policy engine and the audit trail are written without one. Whose credential a + * row goes out on is a SEPARATE axis — `./access`'s `CredentialSource` — and that one is NOT + * protocol-blind, since the brokered branch of the credential selection looks a person's + * connection up in `composio_connections` by name. Read the blindness as a claim about this union + * and not about the store. */ export type VendorTransport = { /** * Whether discovering the tool list needs somebody's credential. * * True for MCP, where the list is an answer from a remote server that will not give it up - * unauthenticated. False for an adapter whose tool list is this code, where there is nothing to ask - * and nobody to ask it of. + * unauthenticated. False whenever no credential has to be SELECTED for the listing: either + * because the list is this code, as it is for Drive and Routines, or because the transport + * already holds the one key it lists on and never receives it through the connection, as + * Composio does — a broker publishes an action's schema to anybody who asks with the + * deployment's own key. * * It is on the transport rather than assumed by the caller because getting it wrong is a whole * broken setup flow. Assumed true, an administrator configuring Drive was sent to their own @@ -41,15 +56,21 @@ export type VendorTransport = { url: string; token?: string; /** - * Who this call is for, and which Bot is making it. + * Declared by the shared connection shape, and never supplied on THIS path. + * + * `refreshTools` is the only caller of `listTools` in the system, and it passes `{url, token}`. + * No implementation here even accepts either field: `builtin-routines` takes no argument at + * all, Composio takes only `url`, and MCP and Drive take `{url, token}`. So a transport that + * read one would read `undefined` every time, and nothing on the listing path may be + * authorized by them. * - * Ignored by every transport that dials a vendor: MCP and Drive answer to a credential, and who - * holds it is already decided by the time the connection is built. The builtin transport has no - * credential and no vendor — it acts on this deployment's own tables — so the actor is not - * context, it is the authorization, and it refuses without one. A routine is somebody's. + * The actor is the authorization on the CALLING path instead — see {@link callTool} below, + * where Routines and Composio each refuse a run attributed to nobody. Listing is not + * somebody's: it is what this deployment offers everybody. A list that insisted on an actor + * would be asked without one, store zero tools, and leave the vendor advertising nothing to + * anybody. */ actorId?: string; - /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ botId?: string; }): Promise; callTool( @@ -59,10 +80,16 @@ export type VendorTransport = { /** * Who this call is for, and which Bot is making it. * - * Ignored by every transport that dials a vendor: MCP and Drive answer to a credential, and who - * holds it is already decided by the time the connection is built. The builtin transport has no - * credential and no vendor — it acts on this deployment's own tables — so the actor is not - * context, it is the authorization, and it refuses without one. A routine is somebody's. + * Ignored where a CREDENTIAL is the authorization: MCP and Drive answer to a token, and whose + * it is was settled before the connection was built, so neither module's `Connection` type + * carries these at all. Read where the ACTOR is the authorization: Routines acts on this + * deployment's own tables, and Composio opens one person's account with a key the deployment + * holds for everybody, so both refuse a run attributed to nobody rather than run it as + * somebody. A routine is somebody's; so is a mailbox. + * + * They come off the connection, which the call path derives from the session, and are never + * read out of `args`. A model that could name either could schedule work as another person or + * read another person's mail. */ actorId?: string; /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ From 840b3d7c2ed1930786760bf3f15d949239a62fc1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:37:55 +0200 Subject: [PATCH 44/93] Keep the Composio SDK out of the runtime image, where nothing imports it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its only importer in the repo is server/tests/composio-live.test.ts; no file under server/src reaches it, because the transport is written against a structural type and takes its client through useComposioClient. The Dockerfile builds the runtime tree with `bun install --production`, so declaring the SDK a dependency put it and five transitive packages — @composio/client, @composio/json-schema-to-zod, is-fs-case-sensitive, pusher-js, tweetnacl, about 18 MB — into every container that runs this product and never calls Composio. The repo's other test-only packages, @copilotkit/aimock and eventsource, are already devDependencies. The lockfile change is the same two lines mirrored: the resolution graph is unchanged, so semver stays hoisted at 7.8.5 and @composio/core stays 0.18.1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- bun.lock | 2 +- server/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index bf28de4f6..462f1e57f 100644 --- a/bun.lock +++ b/bun.lock @@ -66,7 +66,6 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", - "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -80,6 +79,7 @@ "zod": "^4.4.3", }, "devDependencies": { + "@composio/core": "^0.18.1", "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7", diff --git a/server/package.json b/server/package.json index 75153261f..8a963497c 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,6 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", - "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -29,6 +28,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@composio/core": "^0.18.1", "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7" From 14e7b87a9fbc817081faf14627b7a8def54f58d7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:38:35 +0200 Subject: [PATCH 45/93] Refuse the live Composio run that would skip every case and exit 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script set OPENBOT_LIVE_COMPOSIO=1 and nothing else, but the suite is wrapped in describe.skipIf on that flag AND a non-empty COMPOSIO_API_KEY, so `bun run test:live-composio` without a key ran zero cases and reported success — the one outcome a live test must never produce, because it looks exactly like the vendor's shapes having been checked. test:live-screen has no second condition, which is why it does not need this. The guard is in the script rather than the test: the suite must still skip quietly for CI and for a contributor with no Composio account, and only the person who asked for a live run deserves an error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 13f93f99f..cbb29bf31 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "pretest": "bun run generate:app-config", "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts", - "test:live-composio": "OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", + "test:live-composio": "bun -e \"if (!process.env.COMPOSIO_API_KEY?.trim()) { console.error('COMPOSIO_API_KEY is empty or unset, so every case in this suite would skip and this command would still exit 0. Run it as COMPOSIO_API_KEY=... bun run test:live-composio.'); process.exit(1) }\" && OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", "diagram": "bun scripts/architecture-diagram.ts", "mock:knowledge": "bun scripts/mock-knowledge-mcp.ts" }, From 9c8353fbdb86608a58567bfe01555bc9d24e91d9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:38:58 +0200 Subject: [PATCH 46/93] Stop offering operators a deployment setting no deployment reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry in .env.example is a promise that the running product reads that variable, because .env.example is what a deployment copies to make its .env. COMPOSIO_API_KEY was not one: server/src/config.ts never reads it, nothing under server/src mentions it, docs/configuration.md's table omits it, and neither docker-compose.yml nor the Helm env helper forwards it — so an operator who set it got nothing at all, and no error saying so. The comment also claimed absence meant no app could be enabled, which the next line contradicted by admitting no app can be enabled either way. The only thing that reads the key is the opt-in live test, and that is where this repo already documents such a prerequisite: test:live-screen's needs are not in .env.example either. It is now named in the test's own header and in the error the live-Composio script prints without it. Put the entry back when server/src/config.ts reads the key — and in the same change add the row to docs/configuration.md, the passthrough to docker-compose.yml, and the env var to charts/openbot/templates/_helpers.tpl, so the promise is true on every deployment path at once. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .env.example | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.env.example b/.env.example index 1cb267422..6d46581f2 100644 --- a/.env.example +++ b/.env.example @@ -338,8 +338,3 @@ AGENT_TOOL_TOKEN= # for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32. # Do not accept a default in production. WORKER_SHARED_SECRET= - -# Composio, the broker that holds each person's connections to Gmail, Slack, Linear and the rest. -# Absent means absent rather than disabled: no app can be enabled and nothing is registered. -# Only used by the live test in steps 1 and 2; the product does not read it until the app picker ships. -COMPOSIO_API_KEY= From dac4962d2d5a0690452a760fde9a902c024e2c5e Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:40:22 +0200 Subject: [PATCH 47/93] Say why an action list could not be read, in a sentence rather than a Zod dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vendor answer that fails the SDK's own `ToolSchema` threw straight out of `listTools`, so the Zod issue array became the row's `lastError` on the admin page and reached a model's context. It now propagates rather than answering empty — `refreshTools` records a throw and leaves the tools already held alone, whereas an empty list is indistinguishable from an app that publishes no actions and would report a success while stranding every grant — and what propagates names the fix, with the vendor's object kept as `cause`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 62 ++++++++++++++++++++++++- server/tests/composio-transport.test.ts | 35 ++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index e0b0183ca..c7e6def5a 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -133,6 +133,11 @@ export function effectOf(tags: readonly string[] | undefined): { * An action with no schema is still listed, with an open one. The vendor is the right party to reject a * bad argument, and an action silently missing from the list reads to an administrator as an app that * does not have it. + * + * A listing that could not be read at all is a THROW rather than an empty list, for the same reason + * turned around: an empty list is what an app with no actions looks like, so answering emptily would + * report a success and strand every grant. What throws is a sentence, never a vendor object. See the + * catch below. */ export async function listTools(connection: { url: string; @@ -140,7 +145,26 @@ export async function listTools(connection: { const toolkit = toolkitOf(connection.url); if (!toolkit || !installed) return []; - const actions = await installed.listActions(toolkit); + let actions: ComposioAction[]; + try { + actions = await installed.listActions(toolkit); + } catch (error) { + /* + * THROWN, NOT ANSWERED EMPTY, and with a sentence rather than the vendor's raw object. + * + * The two candidate behaviours are not equivalent. `refreshTools` records a throw in the row's + * `lastError` and leaves the tools it already holds alone; an empty answer is indistinguishable + * from an app that genuinely publishes no actions, so it would report a success and leave every + * grant pointing at a name nothing advertises. So a listing this deployment could not read must + * propagate. + * + * What propagates is a sentence. `refreshTools` puts `error.message` on the admin page, and a + * `ToolSchema` mismatch's message is the Zod issue array as JSON — an operator reading 400 + * characters of `{"code":"invalid_type","path":[...]}` learns nothing they can act on, and the + * same string was reaching a model's context. The original is kept as `cause` for a log. + */ + throw new Error(listingSentence(toolkit, error), { cause: error }); + } return actions.map((action) => { const { effect, destructive } = effectOf(action.tags); @@ -179,6 +203,42 @@ export function vendorSentence(error: unknown): string | null { return typeof message === "string" && message.trim() !== "" ? message : null; } +/** + * Whether a thrown listing failure is the SDK's own schema refusing the vendor's answer. + * + * Duck-typed rather than `instanceof ZodError` so this file keeps no dependency on the vendor's + * package: `@composio/core` reaches it only through {@link useComposioClient}, and importing `zod` + * here would tie the transport to whichever major version the vendor happens to bundle — which is + * exactly the coupling that makes a schema mismatch possible in the first place. + */ +function isSchemaMismatch(error: unknown): boolean { + const shaped = error as + | { name?: unknown; issues?: unknown } + | null + | undefined; + return shaped?.name === "ZodError" || Array.isArray(shaped?.issues); +} + +/** + * Why an app's action list could not be read, as one sentence an operator can act on. + * + * The schema case names the fix, because it is a vendor change rather than a misconfiguration: the + * answer arrived and this deployment's copy of their SDK would not accept it, so nothing an + * administrator can do to this row will help and upgrading the package will. + */ +function listingSentence(toolkit: string, error: unknown): string { + if (isSchemaMismatch(error)) { + return `Composio's action list for ${toolkit} did not match the shape this deployment's @composio/core accepts, so the list was not refreshed and the tools already held are untouched. That is a vendor change rather than a setting: upgrading the package is the fix.`; + } + const thrown = error instanceof Error ? error.message.trim() : ""; + return ( + vendorSentence(error) ?? + (thrown === "" + ? `Composio did not answer with an action list for ${toolkit}.` + : thrown) + ); +} + const failure = (message: string): McpCallResult => ({ text: message, isError: true, diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 2b35af995..5617ec7d8 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -211,6 +211,41 @@ describe("listing an app's actions", () => { test("listing with no client installed is empty rather than a crash", async () => { expect(await listTools({ url: "composio://gmail" })).toEqual([]); }); + + test("a listing the vendor's own schema rejects throws a sentence, not a Zod dump", async () => { + const issues = [ + { + code: "invalid_type", + expected: "string", + received: "number", + path: ["slug"], + message: "Expected string, received number", + }, + ]; + useComposioClient( + recording({ + listActions: async () => { + // What `ToolSchema` throws: `message` is the issue array as JSON, which is what would land + // in `lastError` and, before `refreshTools` existed, in a model's context. + throw Object.assign(new Error(JSON.stringify(issues, null, 2)), { + name: "ZodError", + issues, + }); + }, + }).client, + ); + + const listing = listTools({ url: "composio://gmail" }); + + // Propagated rather than answered empty, because `refreshTools` records a throw in `lastError` + // and leaves the tools it already holds alone. An empty answer would read as an app that has no + // actions, and every grant would point at a name nothing advertises. + await expect(listing).rejects.toThrow(/did not match/i); + + const thrown = await listing.catch((error: unknown) => error); + expect(String((thrown as Error).message)).not.toContain("invalid_type"); + expect(String((thrown as Error).message)).toContain("gmail"); + }); }); describe("calling one action", () => { From 17c7c377226c6a1b621c2cce760c187c4f4db9b5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:40:40 +0200 Subject: [PATCH 48/93] Report the outcome the vendor actually reported, and our own failures as ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ToolExecuteResponseSchema` in the installed `@composio/core` 0.18.1 makes `successful` a required field beside a required `data` record and a required nullable `error`, so a 200 answer can carry a failure. This module's comment asserted the opposite — "resolves or throws, with no error field to check" — and `resultOf` was written to match the comment rather than the library, so such an answer came back as `isError: false`, was audited as `mcp.call_succeeded`, and was handed to the model as though the failure were content. The type now says what arrives and the code reads it. Three further ways the same reporting was wrong, all of them here: `failure` skipped the result cap and claimed `truncated: false`, so an unbounded vendor sentence entered a model's context while the field said nothing had been cut; `resultOf` ran inside the vendor's `try`, so a `JSON.stringify` throw of ours was reported as the tool having failed after it ran, which is a different event and now reads as one; and a vendor failure with no nested sentence was reported as "Error executing the tool X", the exact placeholder this module's own comment calls useless, in place of something a person can act on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 168 ++++++++++++++++++--- server/tests/composio-transport.test.ts | 189 +++++++++++++++++++++++- 2 files changed, 329 insertions(+), 28 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index c7e6def5a..6f53174bd 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -47,15 +47,39 @@ export type ComposioAction = { version?: string; }; +/** + * What Composio answers an execute with, as its own SDK defines it. + * + * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells all three of these REQUIRED — `data` + * a record, `error` a nullable string, `successful` a boolean — so the outcome of a call is a field + * on a resolution and not only a thrown exception. Named here rather than imported so this module + * keeps no compile-time dependency on the vendor's package; the adapter that installs the real + * client is the one place their types belong. + * + * `logId` and `sessionInfo` are the rest of the envelope, carried so the type stays a true statement + * about what arrives. Nothing here reads them and nothing here shows them to a model. + */ +export type ComposioResult = { + data: Record; + error: string | null; + successful: boolean; + logId?: string; + sessionInfo?: unknown; +}; + /** * What this module needs of Composio, and nothing more. * * A narrow projection rather than their client, so a test satisfies it with two functions and the * SDK's shape is somebody else's problem in exactly one place: the adapter that installs the real one. * - * `execute` RESOLVES OR THROWS, with no error field to check. That is not a simplification — it is - * what the live API does, confirmed by calling it. An earlier draft of this module checked a - * `{ data, error }` shape that never occurs, so every stubbed test passed against a fiction. + * `execute` RESOLVES AN OUTCOME, AND RESOLVING IS NOT SUCCEEDING. This comment used to say the + * opposite — "resolves or throws, with no error field to check" — and {@link callTool} was written to + * match the comment rather than the library, which is how a 200 answer carrying `successful: false` + * came back from this transport as `isError: false`, was audited as `mcp.call_succeeded`, and was + * handed to the model as though the failure were content. The installed schema is the authority: + * `successful` is required. Throws still happen too, for a transport fault or a 4xx, so both a + * resolution and an exception have to be read. */ export type ComposioActions = { listActions(toolkit: string): Promise; @@ -64,7 +88,7 @@ export type ComposioActions = { userId: string, version: string, args: Record, - ): Promise; + ): Promise; }; let installed: ComposioActions | null = null; @@ -192,8 +216,9 @@ export async function listTools(connection: { * openbot already had this lesson from Drive, where a generic message cost a round of probing and the * vendor's own "The caller does not have permission" named the problem immediately. * - * Null when there is no such sentence, so the caller falls back to the thrown message rather than - * inventing one. + * Null when there is no such sentence, which leaves the caller to choose a fallback rather than + * inventing one here. That choice is not simply "the thrown message": the thrown message is often the + * placeholder above, and passing it on tells the reader nothing. See {@link unexplained}. */ export function vendorSentence(error: unknown): string | null { const cause = (error as { cause?: unknown } | null | undefined)?.cause; @@ -203,6 +228,27 @@ export function vendorSentence(error: unknown): string | null { return typeof message === "string" && message.trim() !== "" ? message : null; } +/** + * The vendor's placeholder, which is the one sentence never worth passing on. + * + * "Error executing the tool GMAIL_FETCH_EMAILS" tells a reader only the name of the thing they asked + * for. Matched on its opening rather than on the whole string, because the slug varies and the + * punctuation after it has not been stable across vendor versions. + */ +const VENDOR_PLACEHOLDER = /^error executing the tool\b/i; + +/** + * What to say when the vendor reported a failure and said nothing about it. + * + * A sentence naming the one thing the reader can actually do, because the alternative is echoing the + * placeholder above — and a model handed "Error executing the tool X" will either retry the identical + * call or invent a reason. The likely cause by a wide margin is a connection that has lapsed, which + * is a person's own two-click fix on the page named here. + */ +function unexplained(toolName: string): string { + return `${toolName} failed and Composio did not say why. Check that this app is still connected on its Plugins page, then try again.`; +} + /** * Whether a thrown listing failure is the SDK's own schema refusing the vendor's answer. * @@ -239,39 +285,83 @@ function listingSentence(toolkit: string, error: unknown): string { ); } +/** + * The cap every string this module puts in front of a model goes through. + * + * Its own function because BOTH ANSWERS NEED IT, and only one of them used to get it. A refusal lands + * in a model's context exactly as a result does, and a vendor's sentence is no shorter for being a + * failure — so {@link failure} capping nothing and reporting `truncated: false` was the silent + * truncation's mirror image: unbounded text, plus a field stating that nothing had been cut. + */ +function cap(text: string): { text: string; truncated: boolean } { + if (text.length <= MAX_RESULT_CHARS) return { text, truncated: false }; + return { + text: `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]`, + truncated: true, + }; +} + const failure = (message: string): McpCallResult => ({ - text: message, + ...cap(message), isError: true, - truncated: false, }); +/** + * The serializations that mean the action had nothing to say. + * + * `{}` is in here because `data` is a required RECORD: an action that matched nothing answers with an + * empty object, so if that did not count as nothing the branch below would be unreachable and its + * promise a fiction. `""` and `"null"` stay for a client whose projection is looser than the schema. + */ +const NOTHING = new Set(["", "null", "{}"]); + /** * What the model reads, capped visibly. * + * THE ACTION'S DATA, NOT THE WHOLE ENVELOPE. `error`, `successful` and `logId` are what + * {@link callTool} reads to decide the outcome; repeating them as content spends a model's context on + * this transport's own bookkeeping and invites the model to draw its own conclusion from a field it + * should never have seen. + * * The same cap the MCP transport applies and for the same reason: a tool result goes straight into a * model's context, so an unbounded one is somebody else's server deciding how much of our context * window to spend. Truncated visibly, never silently. An empty answer is stated in words rather than * returned empty — an empty string reads as "the action had nothing to say" rather than "there is * nothing there", and a model closes that gap from memory. + * + * CAN THROW, and is called from outside the vendor's `try` for that reason. See {@link callTool}. */ -function resultOf(data: unknown): McpCallResult { - const text = - typeof data === "string" ? data : JSON.stringify(data ?? null, null, 2); - const truncated = text.length > MAX_RESULT_CHARS; - if (!truncated && (text === "" || text === "null")) { +function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { + const text = JSON.stringify(data ?? null, null, 2); + if (NOTHING.has(text)) { return { text: "The action returned nothing.", isError: false, truncated: false, }; } - return { - text: truncated - ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` - : text, - isError: false, - truncated, - }; + return { ...cap(text), isError: false }; +} + +/** + * What the vendor said about its own call, read from the field its schema requires it to send. + * + * The criterion is that the vendor SAID the call did not succeed, which is `successful === false` and + * not a falsy `successful`. An absent field is not the vendor reporting a failure — the schema makes + * it impossible from the real client, and reading it as a failure would turn a projection looser than + * the schema into a refusal of a call that worked. + * + * Null when there is nothing to report, so the caller can tell "succeeded" from "failed silently". + */ +function reportedFailure( + answer: ComposioResult, + toolName: string, +): string | null { + if (answer.successful !== false) return null; + const sentence = typeof answer.error === "string" ? answer.error.trim() : ""; + return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) + ? unexplained(toolName) + : sentence; } /** @@ -284,6 +374,12 @@ function resultOf(data: unknown): McpCallResult { * A failure comes back as a result rather than a throw, matching `builtin-routines`. The model is * mid-run with a person waiting; an exception ends the turn with nothing said, and the refusal is in * the audit trail either way. + * + * THREE KINDS OF FAILURE, all of them `isError: true` and each with its own sentence, because + * `store.ts` records that sentence beside the audit row: this transport refused before dialling, the + * vendor reported a failure — by throwing, or in the `successful` field of a 200 answer — or the + * vendor answered and this deployment could not read what it said. Only the last of those is ours, + * and it must not arrive wearing the vendor's words. */ export async function callTool( connection: { url: string; actorId?: string }, @@ -324,15 +420,39 @@ export async function callTool( ); } + /* + * THE VENDOR'S TRY HOLDS THE VENDOR'S CALL AND NOTHING ELSE. + * + * `resultOf` used to be invoked inside it, so a `JSON.stringify` throw of ours — a circular + * reference, a BigInt, a RangeError on something enormous — was reported as the action having + * failed after it ran. Those are two different events: in one the vendor refused, in the other the + * vendor did its part and this deployment could not read the answer. The audit trail has to be able + * to tell them apart, and it cannot if both arrive wearing the vendor's words. + */ + let answer: ComposioResult; try { - return resultOf(await installed.execute(toolName, userId, version, rest)); + answer = await installed.execute(toolName, userId, version, rest); } catch (error) { // The vendor's own sentence when there is one, because a generic message costs a diagnosis. + const thrown = error instanceof Error ? error.message.trim() : ""; return failure( vendorSentence(error) ?? - (error instanceof Error - ? error.message - : "Composio did not answer this action."), + (thrown === "" || VENDOR_PLACEHOLDER.test(thrown) + ? unexplained(toolName) + : thrown), + ); + } + + const reported = reportedFailure(answer, toolName); + if (reported !== null) return failure(reported); + + try { + return resultOf(answer.data); + } catch (error) { + return failure( + `${toolName} ran and Composio answered, but this deployment could not turn that answer into text: ${ + error instanceof Error ? error.message : String(error) + }`, ); } } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 5617ec7d8..3090f9d8e 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -28,6 +28,25 @@ afterEach(() => useComposioClient(null)); type Recorded = { slug: string; userId: string; version: string }; +/** + * An answer in the shape `ToolExecuteResponseSchema` actually permits. + * + * Every stub here goes through this rather than returning a shape of its own, because the SDK's + * schema makes `data`, `error` and `successful` all REQUIRED — so a stub that resolves `null`, or a + * bare string, is testing a case the library cannot produce, and a test built on an impossible input + * proves nothing about the code that reads a real one. + */ +function answered( + data: Record, + outcome: { error?: string | null; successful?: boolean } = {}, +) { + return { + data, + error: outcome.error ?? null, + successful: outcome.successful ?? true, + }; +} + function recording(answers: Partial = {}): { client: ComposioActions; calls: Recorded[]; @@ -41,7 +60,7 @@ function recording(answers: Partial = {}): { answers.execute ?? (async (slug, userId, version) => { calls.push({ slug, userId, version }); - return { ok: true }; + return answered({ ok: true }); }), }, }; @@ -275,7 +294,7 @@ describe("calling one action", () => { recording({ execute: async (_slug, _userId, _version, args) => { seen.push(args); - return {}; + return answered({}); }, }).client, ); @@ -407,7 +426,9 @@ describe("calling one action", () => { test("a result is capped visibly rather than silently", async () => { useComposioClient( - recording({ execute: async () => "x".repeat(60_000) }).client, + recording({ + execute: async () => answered({ body: "x".repeat(60_000) }), + }).client, ); const result = await callTool( @@ -421,7 +442,7 @@ describe("calling one action", () => { }); test("an empty answer says so in words rather than being empty", async () => { - useComposioClient(recording({ execute: async () => null }).client); + useComposioClient(recording({ execute: async () => answered({}) }).client); const result = await callTool( { url: "composio://gmail", actorId: "user_asker" }, @@ -431,6 +452,166 @@ describe("calling one action", () => { // An empty string in front of a model reads as "the action had nothing to say" rather than "there // is nothing there", and the model closes the gap from memory. Same reasoning as `resultText`. + // `data` is a required record, so the empty answer the SDK can actually produce is `{}` — if that + // did not count, this branch would be unreachable and its promise would be a fiction. expect(result.text).toMatch(/returned nothing/i); }); + + test("an answer the vendor marked unsuccessful is a failure, not content", async () => { + // `ToolExecuteResponseSchema` makes `successful` REQUIRED and resolves `{ data, error, + // successful }`, so a 200 answer can carry a failure. Reported as a success it is audited as + // `mcp.call_succeeded` and the failure is handed to the model as though it were content. + useComposioClient( + recording({ + execute: async () => + answered( + {}, + { + successful: false, + error: "Gmail rejected the query: invalid search syntax.", + }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + }); + + test("a successful answer hands the model the action's data and not the envelope", async () => { + useComposioClient( + recording({ + execute: async () => ({ + ...answered({ messages: [{ id: "m1" }] }), + logId: "log_must_not_appear", + }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(false); + expect(result.text).toContain("m1"); + // `successful`, `error` and `logId` are the envelope this transport reads to decide the outcome. + // Reporting them as content spends a model's context on our own bookkeeping. + expect(result.text).not.toContain("log_must_not_appear"); + expect(result.text).not.toContain("successful"); + }); + + test("an unsuccessful answer with no sentence still says something actionable", async () => { + useComposioClient( + recording({ + execute: async () => answered({}, { successful: false, error: null }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + }); + + test("a failure carrying only the vendor's placeholder says something actionable", async () => { + // "Error executing the tool X" is the string this module's own comment calls useless. Echoing it + // tells a person nothing they did not already know: they asked for that tool. + useComposioClient( + recording({ + execute: async () => { + throw new Error("Error executing the tool GMAIL_FETCH_EMAILS"); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).not.toBe("Error executing the tool GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + }); + + test("an enormous vendor sentence is capped in a refusal too, and says so", async () => { + useComposioClient( + recording({ + execute: async () => + answered({}, { successful: false, error: "x".repeat(60_000) }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + // A refusal goes into a model's context exactly as a result does, so an uncapped vendor sentence + // is the same unbounded spend the success path already refuses to make. + expect(result.isError).toBe(true); + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(25_000); + }); + + test("an enormous thrown message is capped in a refusal too", async () => { + useComposioClient( + recording({ + execute: async () => { + throw new Error("y".repeat(60_000)); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(25_000); + }); + + test("our own serialization failure is not reported as the action having failed", async () => { + useComposioClient( + recording({ + execute: async () => { + const data: Record = { subject: "hello" }; + // A circular reference, which `JSON.stringify` refuses. The action already ran and the + // vendor already answered; what fails is this deployment reading that answer. + data.itself = data; + return answered(data); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + // Two different events, and the audit trail has to be able to tell them apart: the vendor did + // its part here. + expect(result.text).toMatch(/could not turn that answer into text/i); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + }); }); From bed3dbd9adde3de70ecc39398dcaeca2a6a2aa41 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:42:45 +0200 Subject: [PATCH 49/93] Keep an app's actions when the refresh listed nothing because it asked nobody Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 382 ++++++++++++------ server/tests/plugin-store.integration.test.ts | 218 +++++++++- 2 files changed, 476 insertions(+), 124 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index fca3fd8da..bf50b722a 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -43,7 +43,7 @@ import { import { accessFor, type ServerAccess } from "./access"; import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; -import { McpServerError } from "./mcp"; +import { type ListedTool, McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; import { transportFor } from "./transport"; @@ -224,6 +224,32 @@ export class CustomServerRefusedError extends Error { } } +/** + * A state this deployment's own code says cannot exist, found existing. + * + * CRITERION. Nothing here is a vendor's doing, a credential's doing or anything a person asking can + * act on, so no path may record one of these as though a vendor had misbehaved. + * + * REASON. `refreshTools` wrapped the listing, the replace and both audit writes in one `catch` that + * copied every message into `lastError` and answered `{ tools: 0 }`. A plain `Error` is what the + * narrowing throws in {@link createPluginStore}'s `connectionTokenFor` raise, so a row that resolved + * to a brokered credential with no app in its url — or to a per-person credential with no + * `user-oauth` entry — came out on the Plugins page as a sentence about the vendor, next to a + * refresh that looked like it had merely failed. An operator reading that is sent to somebody else's + * status page over a contradiction in our own tables. + * + * A class rather than a message, because telling these apart by prose is telling them apart by a + * substring that a reword would silently change. Distinct from {@link PluginRefusedError}, which is + * a refusal somebody CAN act on and which does belong in `lastError` — an administrator who has not + * connected their account is the honest reason a listing did not happen. + */ +export class PluginInvariantError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginInvariantError"; + } +} + /** * The vendor's `error` code, when a token endpoint refuses an exchange. * @@ -833,7 +859,7 @@ export function createPluginStore(options: PluginStoreOptions) { * above stops that narrowing from being the fallback. */ if (!access.toolkit) { - throw new Error( + throw new PluginInvariantError( `${row.id} resolves to a brokered credential with no Composio app in its url.`, ); } @@ -889,7 +915,7 @@ export function createPluginStore(options: PluginStoreOptions) { * says must be impossible. */ if (entry?.auth.kind !== "user-oauth") { - throw new Error( + throw new PluginInvariantError( `${row.id} resolves to a per-person credential with no user-oauth catalogue entry.`, ); } @@ -2106,11 +2132,54 @@ export function createPluginStore(options: PluginStoreOptions) { ): Promise<{ tools: number }> { const { row, entry, access } = await requireServer(serverId); - try { - // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, - // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. - const transport = transportFor(access.transport); + // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, + // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. + const transport = transportFor(access.transport); + + /* + * A brokered row with no app in its url has nobody to ask, and saying so is not the transport's + * job. + * + * CRITERION. A listing this deployment could not even attempt must not be committed as a + * refresh, and must not be written down as a vendor's answer. + * + * REASON. `accessFor` answers `brokered` for every row whose provenance column says so, and + * reads the app slug off the url — so `{ credential: "brokered", toolkit: null }` is a real + * state, which a hand edit or a restored backup produces and nothing in the product does. + * `connectionTokenFor` already refuses it, but only where listing needs a credential, and a + * brokered listing needs none: the broker publishes an action's schema to anybody. So the gate + * was skipped on exactly the path that reaches the vendor with no app named, and the transport + * answered `[]` — indistinguishable, one line later, from an app that advertises nothing. + * + * READ AS FIELDS, not as a transport. `credential` and `toolkit` are both resolved in + * `access.ts` and this asks nothing about which protocol is underneath: any broker reached + * without an app named is unroutable, which is the property `toolkit` is documented to carry. + * A `transport === "composio"` test here would put back the per-call-site derivation that + * module exists to have removed. + */ + if (access.credential === "brokered" && !access.toolkit) { + throw new PluginInvariantError( + `${row.id} resolves to a brokered credential with no app in its url, so there is nothing to ask what it offers.`, + ); + } + /* + * ASKING THE VENDOR, and the only part of this method whose failure is a vendor's. + * + * CRITERION. What lands in `lastError` must be something a vendor, a credential or a person + * could have caused. An invariant this deployment violated and a fault in its own database must + * not read as a vendor misbehaving. + * + * REASON. This used to be one `try` around everything below as well — the wholesale replace, + * the server-row update and both audit writes — with a `catch` that copied any message into + * `lastError` and answered `{ tools: 0 }`. So a statement timeout, a duplicate-key refusal or an + * `audit_events` insert that would not go in all reported a vendor that had in fact answered + * correctly, and reported it beside actions the refresh had already committed. Narrowing that + * by error class would be narrowing by prose; narrowing it by SHAPE is what this split does, so + * a line added below cannot quietly acquire a vendor's excuse. + */ + let listed: ListedTool[]; + try { /* * A credential only when listing actually needs one. * @@ -2130,127 +2199,21 @@ export function createPluginStore(options: PluginStoreOptions) { ? (await connectionTokenFor(row, entry, actorId, access)).token : undefined; - const tools = await transport.listTools({ + listed = await transport.listTools({ url: effectiveUrl(row, entry), token, }); - - /* - * ONE STEP, because the catch below promises that it is one. - * - * "The tools already held are left alone" is only true while nothing has been written yet. - * As two auto-committed statements the delete landed on its own whenever the insert did not: - * a pod killed mid-refresh, a dropped connection, a statement timeout — or, with no crash at - * all, a server that answers `tools/list` with the same `name` twice, which `mcp_tools`' - * `(server_id, name)` primary key refuses as one multi-row insert. `mcp_tools` is shared, so - * that is every replica at once, and nothing repopulates it: `refreshTools` is only ever - * called by `addServer`, `addCustomServer` and an administrator pressing Refresh. The - * connector kept every grant an administrator had made and offered none of them, and - * `grantedToolGuidance` then told the Bot outright that it holds none of that vendor's tools. - * - * Rolled back together, the vendor's bad answer is recorded in `lastError` and the Bots go - * on using what they were granted, which is what the comment said all along. - */ - await database.transaction(async (transaction) => { - await transaction - .delete(mcpTools) - .where(eq(mcpTools.serverId, serverId)); - if (tools.length > 0) { - await transaction.insert(mcpTools).values( - tools.map((tool) => ({ - serverId, - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - /* - * What the vendor said, when the vendor said anything. - * - * Only Composio publishes any of this today, so all three stay null or false for - * every other transport — which is what keeps `classifyTool` falling back to the - * curated write list for Notion and Drive exactly as it did before. - */ - effect: tool.effect ?? null, - destructive: tool.destructive ?? false, - version: tool.version ?? null, - })), - ); - } - }); - - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: new Date(), - lastError: null, - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)); - - /* - * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at - * nothing. - * - * Reporting it on a screen answers "what is true now", which somebody has to go and look at. - * This answers "when did it stop being offered, and what was holding it" — the question asked - * after a transport is swapped back and a name starts resolving again. Without the row, the - * only record of the gap is its absence. - * - * Not a refusal and not an error, so `configuration.changed` rather than a new event type: - * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so - * what it names is what is actually left over. - */ - const advertised = new Set(tools.map((tool) => tool.name)); - const stranded = [...(await mcpGrantsForServers([serverId])).entries()] - .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) - .sort(([left], [right]) => left.localeCompare(right)); - - if (stranded.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: actorId, - change: "grants_not_advertised", - server: serverId, - // The refs, because that is what a grant is keyed on and what an administrator revokes. - refs: stranded.map(([ref]) => ref), - bots: [...new Set(stranded.flatMap(([, agents]) => agents))], - note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", - }, - }); - } - + } catch (error) { /* - * Tools the vendor advertises that this deployment's write list does not name. - * - * The mechanical half of the reconciliation Notion's catalogue entry says is required. See - * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an - * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list - * is silent, and for a vendor with no scope strings there is nothing else standing behind it. + * The narrowing throws in `connectionTokenFor` are ours, not a vendor's. * - * `configuration.changed` rather than a type of its own, the same as the stranded grants - * above and for the same reason: nothing was denied and the refresh succeeded. What changed - * is that the deployment now knows a name it had not classified. + * They fire for a row that resolved to a brokered credential with no app in its url, or to a + * per-person credential with no `user-oauth` entry — contradictions between this deployment's + * own tables and its own code. Raised rather than recorded, so the page does not send whoever + * reads it to somebody else's status page. */ - const unlisted = unlistedAdvertisedTools(entry, [...advertised]); - if (unlisted.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: actorId, - change: "unlisted_tools_advertised", - server: serverId, - tools: unlisted, - note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", - }, - }); - } + if (error instanceof PluginInvariantError) throw error; - return { tools: tools.length }; - } catch (error) { const message = error instanceof McpServerError || error instanceof Error ? error.message @@ -2275,6 +2238,183 @@ export function createPluginStore(options: PluginStoreOptions) { .where(eq(mcpServers.id, serverId)); return { tools: 0 }; } + + /* + * An empty listing never destroys what a real listing recorded. + * + * CRITERION ONE. An empty answer must not be committed as a healthy refresh where doing so + * would delete actions this deployment holds, and must not clear `lastError`. + * + * CRITERION TWO. "This app advertises nothing" stays recordable: an app with nothing held has + * nothing to lose, so the empty answer falls through to the replace below and commits — a + * refresh stamp, no error, no actions. + * + * REASON. `listTools` returning `[]` is supposed to mean "the vendor was asked and advertises + * none", and for three of the four transports it does. `composio.listTools` breaks it: it + * answers `[]` when no client is installed, and NOTHING IN THE SHIPPED PRODUCT CALLS + * `useComposioClient` — so on every real deployment that is the only answer a Composio refresh + * can produce. Committed, it deleted every `mcp_tools` row for the app, taking the recorded + * `effect`, `destructive` and `version` with it. `version` is the one that cannot be + * reconstructed: `callTool` refuses an action without it, so a refresh that reported success + * broke every subsequent call. The grants survived, pointing at rows that no longer existed — + * absent from `listServers`, and revived by a later refresh that worked. + * + * KEPT RATHER THAN TRUSTED, and that asymmetry is the whole argument. Holding actions the vendor + * has withdrawn is visible and reversible: the next listing replaces them. Deleting actions the + * vendor never withdrew is neither — `mcp_tools` is shared, so it is every replica at once, and + * only a refresh from a deployment that can actually reach the vendor puts it back. + * + * THE SEAM REQUIREMENT this leans on, which is not satisfied today: a transport that could not + * ask anybody must THROW rather than return `[]`. `composio.ts:141` returns `[]` for a missing + * client and for a url that names no app, and until it throws instead there is no field on the + * listing that tells the two apart. Nothing here asks which transport it is talking to, so the + * fix belongs in that one line and not in a branch on `access.transport`. + */ + if (listed.length === 0) { + const held = await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + + if (held.length > 0) { + await database + .update(mcpServers) + .set({ + // Named as the state it is, because "listed nothing" and "would not answer" send an + // operator to different places. No `toolsRefreshedAt`: that column says when this + // deployment last learned what the app offers, and it did not learn it here. + lastError: `This app listed no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check that the connector is configured for this deployment, then refresh again.`, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + // What the app advertises, which is what it advertised before: the honest count, because + // nothing was replaced. + return { tools: held.length }; + } + } + + /* + * COMMITTING WHAT THE VENDOR SAID. Nothing from here down is a vendor's doing, so nothing from + * here down is caught — see the criterion on the `try` above. + * + * ONE STEP, because the paragraph above promises the held actions are left alone. + * + * "The tools already held are left alone" is only true while nothing has been written yet. + * As two auto-committed statements the delete landed on its own whenever the insert did not: + * a pod killed mid-refresh, a dropped connection, a statement timeout — or, with no crash at + * all, a server that answers `tools/list` with the same `name` twice, which `mcp_tools`' + * `(server_id, name)` primary key refuses as one multi-row insert. `mcp_tools` is shared, so + * that is every replica at once, and nothing repopulates it: `refreshTools` is only ever + * called by `addServer`, `addCustomServer` and an administrator pressing Refresh. The + * connector kept every grant an administrator had made and offered none of them, and + * `grantedToolGuidance` then told the Bot outright that it holds none of that vendor's tools. + * + * Rolled back together, the Bots go on using what they were granted — and the fault raises + * rather than being copied into `lastError`, because a transaction this database would not take + * is not something the vendor did. + */ + await database.transaction(async (transaction) => { + await transaction + .delete(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + if (listed.length > 0) { + await transaction.insert(mcpTools).values( + listed.map((tool) => ({ + serverId, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes any of this today, so all three stay null or false for + * every other transport — and `classifyTool` reads null as silence rather than as a + * value, which is what leaves Notion and Drive classified by their reviewed write list + * exactly as they were before. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version ?? null, + })), + ); + } + }); + + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: new Date(), + lastError: null, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + + /* + * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at + * nothing. + * + * Reporting it on a screen answers "what is true now", which somebody has to go and look at. + * This answers "when did it stop being offered, and what was holding it" — the question asked + * after a transport is swapped back and a name starts resolving again. Without the row, the + * only record of the gap is its absence. + * + * Not a refusal and not an error, so `configuration.changed` rather than a new event type: + * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so + * what it names is what is actually left over — and only ever after a listing that was + * committed, because the guard above returns before this on an empty answer that would have + * named every grant the app holds. + */ + const advertised = new Set(listed.map((tool) => tool.name)); + const stranded = [...(await mcpGrantsForServers([serverId])).entries()] + .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) + .sort(([left], [right]) => left.localeCompare(right)); + + if (stranded.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: actorId, + change: "grants_not_advertised", + server: serverId, + // The refs, because that is what a grant is keyed on and what an administrator revokes. + refs: stranded.map(([ref]) => ref), + bots: [...new Set(stranded.flatMap(([, agents]) => agents))], + note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", + }, + }); + } + + /* + * Tools the vendor advertises that this deployment's write list does not name. + * + * The mechanical half of the reconciliation Notion's catalogue entry says is required. See + * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an + * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list + * is silent, and for a vendor with no scope strings there is nothing else standing behind it. + * + * `configuration.changed` rather than a type of its own, the same as the stranded grants + * above and for the same reason: nothing was denied and the refresh succeeded. What changed + * is that the deployment now knows a name it had not classified. + */ + const unlisted = unlistedAdvertisedTools(entry, [...advertised]); + if (unlisted.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: actorId, + change: "unlisted_tools_advertised", + server: serverId, + tools: unlisted, + note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", + }, + }); + } + + return { tools: listed.length }; }, async listServers(): Promise { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index f4c5778d4..2abb4e0bd 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -41,6 +41,7 @@ import { exchangeRefreshTokenOverHttp, INVALID_CLIENT, type OAuthClient, + PluginInvariantError, PluginRefusedError, type PluginStore, TokenRefusedError, @@ -2647,9 +2648,14 @@ describe("a dynamic client the vendor has evicted", () => { * The classification an MCP server has always had, over a real listing that really happened. * * The three columns the refresh now writes are Composio's, and every other transport has to keep - * coming out of that insert as null and false — because `classifyTool` prefers a recorded effect to - * the reviewed write list, so a value appearing here for Notion would silently reclassify every one - * of its reads as a write, on a connector nobody touched. Asserted on the rows AND on what the + * coming out of that insert as null and false. `classifyTool` consults the reviewed `writeTools` + * list BEFORE the recorded `effect` column, on the criterion that a recorded value may narrow what + * a Bot is allowed and may never widen it — so a name the list covers stays a write whatever the + * column says, and a value appearing here can no longer turn one of Notion's reviewed writes into a + * read. The other direction is still open, which is what this test is for: a name the list does not + * cover falls through to the column, where PRESENCE rather than truthiness decides, so an effect + * recorded for Notion would silently reclassify every one of its reads as a write, on a connector + * nobody touched. Only `null` and `undefined` are silence. Asserted on the rows AND on what the * Plugins page derives from them, because it is the second one that an administrator reads. * * It lives in this suite because this is the only place a `user-oauth` listing can actually be @@ -4104,3 +4110,209 @@ test("a granted Composio action that the vendor withdrew is still shown as grant "gmail/GMAIL_SEND_EMAIL", ]); }); + +/* + * A refresh that listed nothing, on the path every real deployment takes. + * + * NOTHING IN THE SHIPPED PRODUCT CALLS `useComposioClient`, so `installed` is null on every live + * install and the Composio transport answers `[]` — not a throw — for want of a client to ask with. + * The tests below install no stub, which is that state exactly rather than a fiction about it. + * + * A wholesale replace on that answer deletes what the last real listing recorded, and `version` is + * the column that cannot be reconstructed: `callTool` refuses without it, so an app whose actions + * were dropped this way stops working for everybody until somebody presses Refresh on a deployment + * that can actually reach the vendor. The grants survive the delete pointing at rows that no longer + * exist, which is invisible on the Plugins page and revived by the next refresh that works. + */ +describe("a refresh whose transport could not ask anybody", () => { + test("leaves the actions the app already advertises, with what the vendor said about them", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The version above all: it is what `callTool` sends, so losing it breaks every later call on + // an app the refresh reported as fine. + expect(rows).toEqual([ + { name: "GMAIL_FETCH_EMAILS", effect: "read", version: "20260903_00" }, + ]); + }); + + test("does not report the app as healthy", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + await database + .update(mcpServers) + .set({ lastError: "The vendor would not answer." }) + .where(eq(mcpServers.id, "gmail")); + + await store.refreshTools("gmail", "admin_user"); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + // Not cleared. An empty answer from a transport that reached no vendor is not evidence that + // whatever was wrong before has been put right. + expect(row?.lastError).not.toBeNull(); + // And no refresh stamp, because nothing was listed: the column says when this deployment last + // learned what the app offers, and it did not learn it here. + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("does not strand the grants the app is holding", async () => { + const { store, database, auditStore } = await freshStore(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Still offered and still not withdrawn. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_FETCH_EMAILS", + ]); + expect(gmail?.withdrawn).toEqual([]); + // Nor filed as having stopped being offered, which would be the trail asserting a withdrawal + // the vendor never made. + expect( + auditStore + .recorded() + .filter( + (event) => + (event.payload as { change?: string }).change === + "grants_not_advertised", + ), + ).toEqual([]); + }); + + test("a brokered row whose url names no app raises rather than blaming the vendor", async () => { + const { store, database } = await freshStore(); + // `accessFor` still answers `brokered` for any row whose provenance says composio, so this is + // `{ credential: "brokered", toolkit: null }` — a row a hand edit or an old backup produces and + // nothing in the product can. There is no app to ask, so an empty answer is not the vendor's. + await seedComposioGmail(database, store, { + url: "https://example.com/mcp", + }); + + await expect(store.refreshTools("gmail", "admin_user")).rejects.toThrow( + PluginInvariantError, + ); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + // The state is this deployment's own, so it must not be written down as something a vendor did. + expect(row?.lastError).toBeNull(); + }); +}); + +/** + * The genuine empty listing, which has to stay recordable. + * + * The guard above must not turn "this app advertises nothing" into a state the deployment cannot + * hold, or an app that really offers no actions would read as broken for good. An app with nothing + * recorded against it has nothing to lose, so the empty answer commits: refreshed, no error and no + * actions, which is the honest reading of an app that advertises none. + */ +test("an app with nothing recorded against it can be refreshed to no actions at all", async () => { + const { store, database } = await freshStore(); + useComposioClient({ listActions: async () => [], execute: async () => ({}) }); + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + lastError: "Whatever was wrong last time.", + }); + + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ tools: 0 }); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).not.toBeNull(); +}); + +/** + * An audit write that fails, which is not the vendor misbehaving. + * + * The refresh used to run the listing, the replace, the server-row update and both audit writes + * inside one `catch` that recorded everything as `lastError` and answered `{ tools: 0 }`. So a + * database that would not take an audit row reported a vendor which had in fact answered correctly, + * and reported it against actions the refresh had already committed — sending whoever reads the + * page to a vendor's status page over a fault in their own database. + * + * Its own store rather than {@link freshStore}, because the audit insert is the seam that has to + * fail and that fixture's is deliberately a real one. + */ +test("an audit write that fails is not recorded as the vendor misbehaving", async () => { + const database = await freshDatabase(); + const failing = createPluginStore({ + database, + auditStore: { + insert: async (event) => { + if ( + (event.payload as { change?: string }).change === + "grants_not_advertised" + ) { + throw new Error("audit_events would not take the row"); + } + }, + }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => ({}), + }); + // The granted action is absent from what the vendor now lists, so the refresh reaches the audit + // write about grants nothing advertises — the one the stub above refuses. + await seedComposioGmail(database, failing); + + await expect(failing.refreshTools("gmail", "admin_user")).rejects.toThrow( + "audit_events would not take the row", + ); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + expect(row?.lastError).toBeNull(); +}); From 5015c9ff2adf20e5a6e745373857b8a5555fd12c Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:54:18 +0200 Subject: [PATCH 50/93] Fail the access tests when the slug they resolve is gone A guard that returned instead of asserting made two of these tests pass by doing nothing. The one that matters is the curated-entry-beats-provenance test: its early return silently vacated the property that stops a tampered `provenance = "composio"` turning a reviewed, host-pinned vendor into a brokered one, so a renamed slug would have left that protection unguarded behind a green suite. Both now throw, naming what stopped being checked. The routines row also carried `openbot://routines`, a scheme that exists nowhere else here, so the shape under test was not the shape a row has; it is resolved through `resolveServerUrl` now. The synthetic public entry grew the `https://` its catalogue invariant requires, and joins its row url from its own host and path so the two cannot drift into disagreeing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-access.test.ts | 36 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index be9dcdebf..2187a340c 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; import type { CatalogueEntry } from "../src/plugins/catalogue"; -import { catalogueEntry } from "../src/plugins/catalogue"; +import { catalogueEntry, resolveServerUrl } from "../src/plugins/catalogue"; /** * How a server row is reached, resolved once. @@ -74,12 +74,19 @@ describe("accessFor", () => { }); test("Routines is in-process, with no credential, and acts as the person", () => { - const routines = catalogueEntry("routines"); - if (!routines) return; + // Resolved the way a row is written rather than spelled by hand. The url this used to carry — + // `openbot://routines` — is a scheme this codebase does not have anywhere, so the row shape the + // test claims to cover was not the one being passed in. + const routines = resolveServerUrl("routines"); + if (!routines) { + throw new Error( + "catalogue slug `routines` no longer resolves, so this test asserts nothing about it", + ); + } expect( accessFor( - { provenance: "first-party", url: "openbot://routines" }, - routines, + { provenance: "first-party", url: routines.url }, + routines.entry, ), ).toEqual({ transport: "builtin-routines", @@ -98,7 +105,10 @@ describe("accessFor", () => { title: "Public Thing", vendor: "Somebody", summary: "A server that answers without being told who is asking.", - host: "mcp.example.com", + // Scheme included, because every non-builtin entry carries one — pinned by + // `plugin-catalogue.test.ts`. A bare host here made this stand for an entry the catalogue + // would reject, and the row url below is joined from it so the two cannot drift apart. + host: "https://mcp.example.com", path: "/mcp", auth: { kind: "none" }, writeTools: [], @@ -106,7 +116,10 @@ describe("accessFor", () => { }; expect( accessFor( - { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + { + provenance: "first-party", + url: `${publicEntry.host}${publicEntry.path}`, + }, publicEntry, ), ).toEqual({ @@ -119,7 +132,14 @@ describe("accessFor", () => { test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { const notion = catalogueEntry("notion"); - if (!notion) return; + // Thrown rather than returned. A missing slug here does not make the property hold, it makes + // this test stop checking it — and the whole point of the test is that the protection is never + // unguarded. Renaming the slug must break this file, not quietly empty it. + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here checks that an entry beats provenance", + ); + } // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one, // and must not acquire an app at the broker either — a url edited to `composio://gmail` on a // curated slug is the same tampering by another field. From 02cc2ef87f026397eb86308dad0663966a05b8d1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:53 +0200 Subject: [PATCH 51/93] Arm the Drive test's fetch, so a call made before its stub cannot reach Google Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/google-drive-rest.test.ts | 121 +++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 2700c5293..9130febcc 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -1,8 +1,8 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; -import { callTool as mcpCallTool } from "../src/plugins/mcp"; +import { type McpTool, callTool as mcpCallTool } from "../src/plugins/mcp"; import { transportFor } from "../src/plugins/transport"; /** @@ -19,8 +19,35 @@ const connection = { }; const realFetch = globalThis.fetch; + +/** + * Anything this file did not stub is an escape, and an escape fails the test that let it out. + * + * Ordering used to matter here: a test awaited `listTools` before installing its stub, and got away + * with it only because this adapter's list happens to be a local constant. Reordering that one call + * fixes it once; a call made before its stub would go out to Google again the moment any of these + * functions grows a request. So `fetch` is armed to refuse instead, before every test. + * + * Refusing is not enough on its own — the adapter catches its own transport errors and reports them + * as a sentence, which would turn an escape into a plausible-looking failure message. The escapes + * are therefore recorded and the ledger asserted empty afterwards, so one is named as what it is + * rather than read as Drive being unreachable. + */ +let escapedToNetwork: string[] = []; + +beforeEach(() => { + escapedToNetwork = []; + // Annotated as answering, though it never does: a function that only throws infers as returning + // `never`, which does not overlap `fetch` enough for the cast the stub below makes freely. + globalThis.fetch = (async (input: string | URL): Promise => { + escapedToNetwork.push(String(input)); + throw new Error(`unstubbed fetch escaped to the network: ${String(input)}`); + }) as typeof fetch; +}); + afterEach(() => { globalThis.fetch = realFetch; + expect(escapedToNetwork).toEqual([]); }); /** Records what was requested and answers with a fixed body. */ @@ -43,8 +70,31 @@ function stubFetch( return calls; } +/** + * Arguments a tool will accept, read off the tool's own schema rather than written out here. + * + * A tool added to the adapter with a required argument this file has never heard of still gets + * called with one, so the coverage below cannot quietly stop covering it. + */ +function argsFor(tool: McpTool): Record { + const required: unknown[] = Array.isArray(tool.inputSchema.required) + ? tool.inputSchema.required + : []; + return Object.fromEntries(required.map((name) => [String(name), "given"])); +} + +/** + * A body that answers every advertised tool: a listing for the searches, a text file for the reads. + */ +const anyToolsBody = { + files: [], + id: "given", + name: "notes.txt", + mimeType: "text/plain", +}; + describe("the adapter is the transport the catalogue asks for", () => { - test("the Drive entry resolves to this adapter, not to MCP", async () => { + test("the Drive entry resolves to this adapter, not to MCP", () => { const entry = catalogueEntry("google-drive"); expect(entry?.transport).toBe("google-drive-rest"); // Identity, not shape: proves the registry wired this module rather than something MCP-shaped. @@ -73,15 +123,70 @@ describe("the adapter is the transport the catalogue asks for", () => { }); test("every advertised tool is one the dispatcher handles", async () => { + // Stubbed before the first call of any kind, so nothing here depends on `listTools` staying + // local; the guard above turns a reintroduction of that order into a named failure. + stubFetch(anyToolsBody); const tools = await listTools(connection); - stubFetch({ files: [] }); + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools) { - // Called with no arguments on purpose. A handled tool complains about a missing argument or - // answers; an unhandled one says it is not implemented, which is the failure being excluded. - const result = await callTool(connection, tool.name, {}); - expect(result.text).not.toContain("is not a tool this connector"); + /* + * Called with what the tool asks for, and asserted on what a handled tool DOES: it reaches + * Drive and answers. The dispatcher's fallthrough is the failure being excluded, and it is + * excluded by never making a request — which stays true however that refusal is worded, and + * which a reworded, mistyped or entirely different error cannot satisfy. + */ + const calls = stubFetch(anyToolsBody); + const result = await callTool(connection, tool.name, argsFor(tool)); + expect(calls.length).toBeGreaterThan(0); + expect(result.isError).toBe(false); } }); + + test("a tool the dispatcher does not implement is refused without a request", async () => { + // The other half of the pair: the fallthrough exists, and is what a tool NOT in the list gets. + const calls = stubFetch(anyToolsBody); + const result = await callTool(connection, "delete_everything", {}); + + expect(result.isError).toBe(true); + expect(calls).toHaveLength(0); + }); +}); + +/* + * Drive is `user-oauth`, so the store refuses a call with nobody's credential long before this + * module is reached and the adapter's own check is the second lock. It is asserted anyway: it is + * the difference between a sentence saying so and a request to Google carrying `Bearer undefined`, + * which Drive answers with a 401 whose meaning is a great deal less obvious. + */ +describe("a call with no credential never leaves the process", () => { + const withoutToken = { url: connection.url }; + + test("every advertised tool refuses, and none of them requests anything", async () => { + stubFetch(anyToolsBody); + const tools = await listTools(withoutToken); + expect(tools.length).toBeGreaterThan(0); + + for (const tool of tools) { + const calls = stubFetch(anyToolsBody); + const result = await callTool(withoutToken, tool.name, argsFor(tool)); + // Silence first, and asserted as silence rather than as wording: nothing was requested, so + // no `Bearer undefined` went to Google to come back as a 401 about the wrong thing. + expect(calls).toHaveLength(0); + expect(result.isError).toBe(true); + } + }); + + test("listing what the adapter offers asks nobody, so it needs nothing", async () => { + /* + * Two properties in one call, and the second is why this test is left unstubbed. The gate that + * once stood here made connecting Drive a four-stop journey, so a tokenless listing has to + * answer in full — and the reason it can is that it asks nobody, which the armed `fetch` above + * is what proves. A `listTools` that grew a request would fail here by name. + */ + expect(await listTools(withoutToken)).toEqual(await listTools(connection)); + expect(escapedToNetwork).toEqual([]); + }); }); describe("a search becomes the right Drive request", () => { From 559dd99d008ac6db6b220dd3393545d3464323a0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:58:22 +0200 Subject: [PATCH 52/93] Make the live test watch the vendor instead of erasing what it watches Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-live.test.ts | 174 ++++++++++++++++++++++++----- 1 file changed, 149 insertions(+), 25 deletions(-) diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts index 39d478d24..62e35198c 100644 --- a/server/tests/composio-live.test.ts +++ b/server/tests/composio-live.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { Composio } from "@composio/core"; +import { + Composio, + ComposioError, + ComposioToolVersionRequiredError, + type ToolExecuteResponse, +} from "@composio/core"; import { effectOf, vendorSentence } from "../src/plugins/composio"; /** @@ -11,38 +16,61 @@ import { effectOf, vendorSentence } from "../src/plugins/composio"; * them passed the whole stubbed suite. A stub asserts what its author believed. This asserts what the * vendor does. * + * WRITTEN AGAINST THE VENDOR'S TYPES, WITH NO `as never`. `server/tsconfig.json` does not include + * `tests`, so nothing in the build type-checks this file — which means a cast here erases the only + * place in the repo where the real SDK surface is named, and erasing it defeats the one job the file + * has. Every call below is typed by `@composio/core` itself. Where a value has to be narrowed, it is + * narrowed by a runtime check that says what was missing, not by a cast that asserts it was there. + * * SKIPPED WITHOUT A KEY, so CI and a contributor with no Composio account are unaffected. Run it * deliberately: `OPENBOT_LIVE_COMPOSIO=1 COMPOSIO_API_KEY=... bun test tests/composio-live.test.ts`. * - * IT READS AND IT FAILS ON PURPOSE. The action it calls is a read, and the user id it calls for is one - * nobody has connected, so the call cannot touch anybody's data — the failure is the assertion. + * IT READS AND IT FAILS ON PURPOSE. Every action it calls is a read; the user id it calls for is one + * nobody has connected, and the one action it calls that is allowed to reach a third party is a + * no-auth public lookup of a record that does not exist. So the calls cannot touch anybody's data — + * the failure is the assertion. */ const key = process.env.COMPOSIO_API_KEY?.trim(); const live = process.env.OPENBOT_LIVE_COMPOSIO === "1" && Boolean(key); +/** A user id nobody has connected, so no call below can reach an account that belongs to somebody. */ +const NOBODY = "openbot-live-test-nobody"; + +/** + * The vendor's own no-auth example action, and a record it cannot find. + * + * `@composio/core` 0.18.1 uses `HACKERNEWS_GET_USER` in three of its own `tools.execute` doc examples + * with no connected account in sight, which is why it is the action picked to provoke a failure the + * vendor reports rather than throws. The test below re-checks the vendor's `isNoAuth` label before it + * calls, so a toolkit that stops being no-auth says so instead of quietly asking for a connection. + */ +const NO_AUTH_ACTION = "HACKERNEWS_GET_USER"; +const NO_SUCH_RECORD = "openbot-live-test-no-such-hacker-news-user"; + describe.skipIf(!live)("Composio, for real", () => { // Constructed inside each test rather than here, because Bun evaluates the body of a skipped // describe: the constructor throws without a key, which would make this file fail rather than skip. const client = () => new Composio({ - apiKey: key as string, + apiKey: key, // Their default telemetry installs its own interrupt handlers, and this is a self-hosted product // whose operator never opted into a third party's analytics. allowTracking: false, disableVersionCheck: true, - } as never); + // Pinned so the result is the same on every machine. `getToolkitVersionsFromEnv` folds any + // exported `COMPOSIO_TOOLKIT_VERSION_` into this config, and a version test whose answer + // depends on the operator's shell reports the operator's environment as a vendor change. + // Config wins over the environment, so naming the toolkits this file touches settles it. + toolkitVersions: { gmail: "latest", hackernews: "latest" }, + }); test("a listing carries a version and a behaviour label for every action", async () => { const composio = client(); - const actions = (await composio.tools.getRawComposioTools({ + const actions = await composio.tools.getRawComposioTools({ toolkits: ["gmail"], // Explicit, because their default page is 20 and Gmail has 63. limit: 500, - } as never)) as { - slug: string; - version?: string; - tags?: string[]; - }[]; + }); expect(actions.length).toBeGreaterThan(50); expect(actions.every((action) => Boolean(action.version))).toBe(true); @@ -65,18 +93,33 @@ describe.skipIf(!live)("Composio, for real", () => { test("calling for somebody with no connection fails with a sentence naming that", async () => { const composio = client(); - const [action] = (await composio.tools.getRawComposioTools({ + const [action] = await composio.tools.getRawComposioTools({ toolkits: ["gmail"], limit: 1, - } as never)) as { slug: string; version: string }[]; + }); + // Stated rather than destructured into a crash. A vendor that lists nothing has not disagreed + // with the assertion below, it has left the assertion unmade, and the two want opposite reactions. + if (!action) { + throw new Error( + "Precondition not met: Composio listed no Gmail action, so there was nothing to call. Nothing below was exercised.", + ); + } + // `Tool.version` is optional in `ToolSchema`, so the concrete version this call needs is a + // precondition and not something to assert into existence with a cast. + const version = action.version; + if (!version) { + throw new Error( + `Precondition not met: Composio listed ${action.slug} with no version, so no versioned call could be made. Nothing below was exercised.`, + ); + } let thrown: unknown; try { await composio.tools.execute(action.slug, { - userId: "openbot-live-test-nobody", + userId: NOBODY, arguments: {}, - version: action.version, - } as never); + version, + }); } catch (error) { thrown = error; } @@ -87,28 +130,109 @@ describe.skipIf(!live)("Composio, for real", () => { expect(vendorSentence(thrown)).toMatch(/no connected account/i); }); - test("a call without a version is refused, and 'latest' is not a version", async () => { + /** + * NOT A CHECK ON COMPOSIO, and named that way now because it used to be named as though it were. + * + * `executeComposioTool` throws `ComposioToolVersionRequiredError` on the statement directly above + * its own `try` — `@composio/core` 0.18.1, `dist/index.mjs:1728` — before the request body is built + * and before anything is sent. So no answer from Composio is involved and none can drift; what this + * watches is the SDK's local guard. + * + * Kept rather than deleted, because that guard is the reason the transport carries a version column + * at all. If the SDK ever stops refusing, `latest` becomes reachable and the column, and the refusal + * this transport inherits from it, are both worth revisiting. + */ + test("the SDK refuses locally, before dialling, when a version resolves to 'latest'", async () => { const composio = client(); - const [action] = (await composio.tools.getRawComposioTools({ + const [action] = await composio.tools.getRawComposioTools({ toolkits: ["gmail"], limit: 1, - } as never)) as { slug: string }[]; + }); + if (!action) { + throw new Error( + "Precondition not met: Composio listed no Gmail action, so there was nothing to call. Nothing below was exercised.", + ); + } - for (const version of [undefined, "latest"]) { + // Both spellings of "no concrete version": omitted, which the SDK resolves through the config to + // `latest`, and `latest` asked for by name. `body.version ?? getToolkitVersion(...)` reads an + // explicit `undefined` exactly as it reads an absent key, so passing it is the omitted case. + for (const version of [undefined, "latest"] as const) { let thrown: unknown; try { await composio.tools.execute(action.slug, { - userId: "openbot-live-test-nobody", + userId: NOBODY, arguments: {}, - ...(version ? { version } : {}), - } as never); + version, + }); } catch (error) { thrown = error; } - // If either of these ever stops throwing, the version column and its refusal can be revisited. - expect((thrown as { code?: string })?.code).toBe( + expect(thrown).toBeInstanceOf(ComposioToolVersionRequiredError); + // Read off a narrowed instance rather than cast onto `unknown`: `ComposioError.code` is + // declared `string | undefined`, and the `TS-SDK::` prefix is the SDK stamping its own errors. + expect(thrown instanceof ComposioError ? thrown.code : null).toBe( "TS-SDK::TOOL_VERSION_REQUIRED", ); } }); + + /** + * The one that mattered, and the one that was missing. + * + * A 200 carrying `successful: false` came back from this transport as `isError: false`, was audited + * as `mcp.call_succeeded`, and was handed to the model as though the failure were content. The fix + * reads the field; nothing here checked that the real API still sends it, so the exact drift the fix + * was about was unguarded against the vendor. + * + * `ToolExecuteResponseSchema` makes `successful` required and `transformToolExecuteResponse` parses + * every answer through it, so an SDK resolution is guaranteed to carry the field. What only a live + * call can show is the other half: that the vendor reports a failure by RESOLVING with that field + * set to false, and not only by throwing. If this ever throws instead, `reportedFailure` in the + * transport has become dead code and the throw path is carrying the whole load. + */ + test("a failure the vendor reports arrives as a resolution, not only as a throw", async () => { + const composio = client(); + const [action] = await composio.tools.getRawComposioTools({ + tools: [NO_AUTH_ACTION], + }); + if (!action) { + throw new Error( + `Precondition not met: Composio does not list ${NO_AUTH_ACTION}, so no unauthenticated call could be made. Nothing below was exercised.`, + ); + } + if (action.isNoAuth !== true) { + throw new Error( + `Precondition not met: Composio no longer marks ${NO_AUTH_ACTION} as no-auth, so calling it would need somebody's connected account. Pick another no-auth action rather than connecting one.`, + ); + } + const version = action.version; + if (!version) { + throw new Error( + `Precondition not met: Composio listed ${action.slug} with no version, so no versioned call could be made. Nothing below was exercised.`, + ); + } + + let thrown: unknown; + let answer: ToolExecuteResponse | undefined; + try { + answer = await composio.tools.execute(action.slug, { + userId: NOBODY, + // A public lookup of a record that does not exist: the request reaches the vendor, and the + // vendor has nothing of anybody's to return. + arguments: { userId: NO_SUCH_RECORD }, + version, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + expect(answer?.successful).toBe(false); + // `ComposioResult` in the transport spells `error` as `string | null`, and a reported failure that + // says nothing is what `unexplained()` exists for — so null is legal here and a third type is drift. + expect(answer?.error === null || typeof answer?.error === "string").toBe( + true, + ); + }); }); From 54e63fa8dff825aa3c84ef13bac75db9e3362de6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:53:33 +0200 Subject: [PATCH 53/93] Give the Composio stubs answers the vendor could actually send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `execute` stub answered `{}` or `{ messages: [] }`, neither of which `ToolExecuteResponseSchema` in @composio/core 0.18.1 can produce: it spells `data`, `error` and `successful` all required. Nothing caught it because server/tsconfig.json excludes tests, and at runtime the shapes stayed green for the wrong reason — an absent `successful` is not `successful === false`, so the transport's failure branch was never the thing being exercised. A typed helper now holds the envelope, so the success path's greenness is about the success path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 2abb4e0bd..b398bf0a0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -32,7 +32,10 @@ import { users, } from "../src/db/schema"; import { catalogueEntry } from "../src/plugins/catalogue"; -import { useComposioClient } from "../src/plugins/composio"; +import { + type ComposioResult, + useComposioClient, +} from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; import { type AccessToken, @@ -3482,6 +3485,28 @@ async function seedComposioGmail( ); } +/** + * What Composio answers a call that worked, in the shape its own schema requires. + * + * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells `data`, `error` and `successful` + * REQUIRED. Every stub below used to answer `{}` or `{ messages: [] }`, which are shapes the vendor + * cannot produce, and nothing flagged it: `server/tsconfig.json` excludes `tests`, so no typecheck + * reads these files at all. They stayed green for a reason that is not the property under test — + * an ABSENT `successful` is not `successful === false`, so the transport's failure branch was + * simply never entered. A stub that can only answer things the vendor could actually say is what + * makes the success path's greenness mean something. + * + * Typed as {@link ComposioResult} rather than left to inference, so a vendor shape that drifts is a + * red squiggle here even though the suite is outside the typecheck's reach. + */ +const vendorAnswered = ( + data: Record = {}, +): ComposioResult => ({ + data, + error: null, + successful: true, +}); + // The vendor is a process-wide registry, so a stub outliving its test would be answering somebody // else's calls. afterEach(() => useComposioClient(null)); @@ -3544,7 +3569,7 @@ test("a Composio app is listed through the Composio transport, not dialled as MC asked.push(toolkit); return []; }, - execute: async () => ({}), + execute: async () => vendorAnswered(), }); await database.insert(mcpServers).values({ id: "gmail", @@ -3569,7 +3594,7 @@ test("a Composio call with nobody attributed is refused before it reaches the ve listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3611,7 +3636,7 @@ test("a Composio call with nobody attributed is refused even when a connection r listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3646,7 +3671,7 @@ test("a Composio call by somebody who has not connected the app is refused with listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store, { connect: false }); @@ -3672,7 +3697,7 @@ test("a Composio call whose url names no app is refused rather than falling back listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); // Brokered by provenance, with a url that names no Composio app: `accessFor` answers @@ -3699,7 +3724,7 @@ test("a Composio call whose row id and url name different apps is refused", asyn listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); // The row is called `gmail` and the person has connected `gmail`; the url dials Slack, which is @@ -3729,7 +3754,7 @@ test("a Composio call sends the version recorded for that action", async () => { listActions: async () => [], execute: async (slug, _userId, version) => { calls.push({ slug, version }); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3762,7 +3787,7 @@ test("a version a model supplied in its own arguments cannot beat the recorded o listActions: async () => [], execute: async (slug, _userId, version) => { calls.push({ slug, version }); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3791,7 +3816,7 @@ test("a version a model supplied cannot stand in for an action with none recorde listActions: async () => [], execute: async (slug, _userId, version) => { calls.push({ slug, version }); - return {}; + return vendorAnswered(); }, }); // The action with no recorded version, which is the branch the test above does not cover: there @@ -3824,7 +3849,7 @@ test("a Composio call is recorded as reaching the vendor as the person, not as t const { store, database, auditStore } = await freshStore(); useComposioClient({ listActions: async () => [], - execute: async () => ({ messages: [] }), + execute: async () => vendorAnswered({ messages: [] }), }); await seedComposioGmail(database, store); @@ -3938,7 +3963,7 @@ test("a brokered call is judged by the effect the vendor recorded, not by the ab const { store, database, auditStore } = await freshStore(); useComposioClient({ listActions: async () => [], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); // `effect: "read"` on the seeded action, and no catalogue entry for `gmail` at all — so the two // sources disagree and the row records which one decided. @@ -4012,7 +4037,7 @@ test("refreshing a Composio app records each action's effect, destructive marker version: "20260903_00", }, ], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); await database.insert(mcpServers).values({ @@ -4086,7 +4111,7 @@ test("a granted Composio action that the vendor withdrew is still shown as grant version: "20260903_00", }, ], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); await seedComposioGmail(database, store); @@ -4234,7 +4259,10 @@ describe("a refresh whose transport could not ask anybody", () => { */ test("an app with nothing recorded against it can be refreshed to no actions at all", async () => { const { store, database } = await freshStore(); - useComposioClient({ listActions: async () => [], execute: async () => ({}) }); + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(), + }); await database.insert(mcpServers).values({ id: "gmail", title: "Gmail", @@ -4299,7 +4327,7 @@ test("an audit write that fails is not recorded as the vendor misbehaving", asyn version: "20260903_00", }, ], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); // The granted action is absent from what the vendor now lists, so the refresh reaches the audit // write about grants nothing advertises — the one the stub above refuses. From 0b08915ebf96aec9febc17bd7b2518dc47c0bd9e Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:23 +0200 Subject: [PATCH 54/93] Observe at the vendor which person's account a Composio call opens The deployment holds one Composio key and the id sent beside it decides whose mailbox the call reaches, so that id is the whole authorization. Every stub in this file took `_userId` and discarded it, which left the load-bearing claim of the brokered transport unobserved: a store that sent `input.botId`, or "", or a constant, passed all of them. The stub now records the id and the test names the asking person. Sending the Bot's id instead fails here and nowhere else in the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b398bf0a0..0c4b2202d 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3845,6 +3845,45 @@ test("a version a model supplied cannot stand in for an action with none recorde expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); }); +/* + * WHOSE ACCOUNT THE CALL OPENS, OBSERVED AT THE VENDOR. + * + * This is the claim the whole brokered transport exists to make, and until these two tests it was + * the one thing nothing looked at. The deployment holds ONE Composio key; which person's mailbox a + * call opens is decided entirely by the id sent beside it. Every stub in this file took `_userId` + * and threw it away, so a store that sent the Bot's id, or the empty string, or a constant, passed + * all of them — the property held by construction and nothing would have noticed it stopping. + * + * `execute`'s SECOND positional argument is that id. Recorded here rather than counted, so a + * regression fails naming the id that actually went out. + */ +test("a Composio call reaches the vendor as the person asking, not as the Bot", async () => { + const { store, database } = await freshStore(); + const reached: { slug: string; userId: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, userId) => { + reached.push({ slug, userId }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + // `user_asker`, not `bot_helper` and not "". The id comes off the connection the call path built + // from the session — `app.ts` takes it from a credential assertion and `routes.ts` from the + // session — and it is the only thing standing between this Bot and somebody else's mailbox. + expect(reached).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", userId: "user_asker" }, + ]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From c9e132c8ec33b7775be4106b9020e70d697aeebd Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:48 +0200 Subject: [PATCH 55/93] Hold the forged-identity case to the arguments it actually sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test named for this asserted nothing about arguments at all. A model can name somebody else three plausible ways — `userId`, `user_id`, `entityId` — and all three reach the vendor verbatim, because the identity is `execute`'s second positional argument and `args` is the fourth. Forwarding them is correct rather than a leak: Composio spells arguments snake_case, `user_id` is an ordinary parameter name, and swallowing it would drop an argument the person meant. So this asserts both halves, the asker as the identity and the arguments untouched but for the reserved version key. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 0c4b2202d..8ea8653d0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3884,6 +3884,68 @@ test("a Composio call reaches the vendor as the person asking, not as the Bot", ]); }); +/** + * An identity a model wrote into its own arguments does not become the identity of the call. + * + * THE THREE KEYS ARE THE ONES THAT WOULD WORK IF ANYTHING READ THEM. `userId` is the parameter + * name on the transport's own projection, `user_id` is how Composio spells arguments, and + * `entityId` is what their SDK called this before it was renamed — so a model that guessed at any + * of the three would be guessing well. + * + * FORWARDED, NOT STRIPPED, AND THAT IS THE CORRECT BEHAVIOUR. The identity is `execute`'s second + * POSITIONAL argument, taken from `connection.actorId`; `args` is the fourth and reaches the vendor + * as the action's own parameters. Nothing on that path reads `args` looking for an identity, which + * is what makes this structural rather than checked. Stripping the keys instead would be a bug with + * a real victim: Composio's schemas are snake_case, `user_id` is an ordinary parameter name on real + * actions, and a transport that swallowed it would quietly drop an argument the person meant. So + * this asserts BOTH halves — the vendor is handed the asker as the identity, and it is handed the + * model's arguments untouched. + * + * The absent `__version` is the other half of the same statement: the reserved key is the ONLY + * thing removed from what a model sent. + */ +test("an identity a model puts in the arguments does not change whose account the call opens", async () => { + const { store, database } = await freshStore(); + const reached: { userId: string; args: Record }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (_slug, userId, _version, args) => { + reached.push({ userId, args }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + // A model naming somebody else, three ways, beside one argument it genuinely meant. + args: { + userId: "user_stranger", + user_id: "user_stranger", + entityId: "user_stranger", + query: "is:unread", + }, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(reached).toEqual([ + { + // The session's person. None of the three keys reached the identity, because the identity is + // not read from arguments at all. + userId: "user_asker", + // Passed through whole, minus nothing: the reserved version key is the only thing the call + // path removes, and the model sent none. + args: { + userId: "user_stranger", + user_id: "user_stranger", + entityId: "user_stranger", + query: "is:unread", + }, + }, + ]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From 2c8a7bc95b5b7c89f4303b50f14beb8753890942 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:58:49 +0200 Subject: [PATCH 56/93] Hold the trail to calling a vendor's refusal a failure The event name is derived from `result.isError`, and nothing looked at the derivation: replacing it with a constant `mcp.call_succeeded` left the suite green, so the trail could have asserted success about every refused call. The one test that reads as covering this exercises the throw path instead, where the catch branch spells the name literally. The second test gates the conflation rather than hiding it: our own unreadable answer lands under the same event name as the vendor's refusal, and only the prose in `payload.failure` tells an operator which of the two happened. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 8ea8653d0..c534e8020 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3507,6 +3507,18 @@ const vendorAnswered = ( successful: true, }); +/** + * What Composio answers when it ran nothing and says why, which is a 200 and not a throw. + * + * `successful: false` beside a sentence is the vendor reporting its own failure inside the + * envelope, which is the case that used to come back from this transport as `isError: false`. + */ +const vendorRefused = (sentence: string): ComposioResult => ({ + data: {}, + error: sentence, + successful: false, +}); + // The vendor is a process-wide registry, so a stub outliving its test would be answering somebody // else's calls. afterEach(() => useComposioClient(null)); @@ -3972,6 +3984,114 @@ test("a Composio call is recorded as reaching the vendor as the person, not as t expect(call?.payload).toMatchObject({ reachedAs: "user_asker" }); }); +/** + * Every call event this suite's store recorded against one tool, in order. + * + * Named because the assertions below are about WHICH event was written, and reading that off an + * unfiltered list would also pick up the grant the fixture makes. `mcp.call_` is the prefix the + * three outcomes share. + */ +function callEventsFor( + auditStore: { recorded: () => { eventType: string; targetId?: string }[] }, + targetId: string, +) { + return auditStore + .recorded() + .filter( + (event) => + event.targetId === targetId && event.eventType.startsWith("mcp.call_"), + ); +} + +/** + * A vendor that reported its own failure is filed as a failure, not as a success. + * + * `mcp.call_failed` is derived from `result.isError` and nothing asserted the derivation: flipping + * the two event names left the suite green, so the trail could have said `mcp.call_succeeded` about + * every refused call and the only surface that counts successes would have agreed. That is the same + * class of defect as the one the comment above the try block describes — a trail asserting the + * opposite of what happened — and it was still open on this branch. + * + * The sentence matters as much as the name. `payload.failure` is the vendor's own words, and it is + * the most useful thing an operator gets: it is what turned "the connector is broken" into "the + * connection lapsed" on the Drive path. + */ +test("a call the vendor refused is filed as failed, with the vendor's own sentence", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => + vendorRefused("Gmail rejected the request: bad label."), + }); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(true); + + // Exactly one call row, and it is the failure. Asserted as the whole list rather than by finding + // a failure in it, because a `find` passes just as happily when a `mcp.call_succeeded` row sits + // beside it — and a success row for a refused call is the thing being ruled out. + expect(callEventsFor(auditStore, "gmail/GMAIL_FETCH_EMAILS")).toMatchObject([ + { + eventType: "mcp.call_failed", + payload: { + actor: "user_asker", + bot: "bot_helper", + failure: "Gmail rejected the request: bad label.", + }, + }, + ]); +}); + +/** + * Our own unreadable answer is filed under the SAME name as the vendor's refusal. + * + * GATED AS IT BEHAVES TODAY, AND THE CONFLATION IS THE FINDING. `callTool` in `composio.ts` is + * careful to keep these two apart — the vendor's `try` holds the vendor's call and nothing else, + * precisely so a `JSON.stringify` throw of ours is not reported as the action having failed — and + * then `store.ts` collapses the distinction again on the way to the trail, because the event name + * is derived from `isError` alone and both are `isError: true`. So the only thing telling an + * operator "Composio refused" from "Composio answered and we could not read it" is the sentence in + * `payload.failure`, which is prose and not a queryable field. A reader counting `mcp.call_failed` + * to decide whether a connector is healthy cannot separate a vendor fault from a bug of ours. + * + * A circular `data` is the honest way to reach it: `resultOf` stringifies whatever the vendor sent, + * and a structure that cannot be serialized is one of the three faults its comment names. + */ +test("an answer this deployment could not read is filed under the same name as a vendor refusal", async () => { + const { store, database, auditStore } = await freshStore(); + const circular: Record = {}; + circular.itself = circular; + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(circular), + }); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(true); + + const [event] = callEventsFor(auditStore, "gmail/GMAIL_FETCH_EMAILS"); + // The same event name the vendor's own refusal gets, one test above. + expect(event?.eventType).toBe("mcp.call_failed"); + // And the sentence is the only thing that says this one was ours. + expect((event?.payload as { failure?: string } | undefined)?.failure).toMatch( + /could not turn that answer into text/, + ); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); From aa52f27bf11ea3563d04f37d980cbed7819e5fcf Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:02:27 +0200 Subject: [PATCH 57/93] Refuse a brokered call from somebody a colleague's connection would carry The connection gate reads composio_connections for the app AND the asking person, but every test around it seeded a database where the app was connected by the asker or by nobody, so dropping the actor term from that where clause left the whole suite green. Without it the question becomes "has anybody connected Gmail", the first colleague to connect makes the app callable by everybody, and the broker is handed a stranger's id. This is the defect the brokered transport exists to prevent, and it is now the one thing that fails when the term goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index c534e8020..c5bb97f8d 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3702,6 +3702,51 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +/** + * One person having connected the app is not the asking person having connected it. + * + * THE FAIL-OPEN THIS CLOSES. The gate reads `composio_connections` for `(toolkit, actorId)`, and + * every test around it seeds a database where the app is connected by the asker or by nobody at + * all — so dropping `eq(composioConnections.userId, actorId)` from that `where`, which turns the + * question into "has ANYBODY connected Gmail", left the whole suite green. That single term is what + * keeps one person's mailbox out of another's: with it gone, the first colleague to connect Gmail + * makes the app callable by everybody, the broker is handed the stranger's id, and Composio answers + * with whatever account it holds for them — or refuses in words that read as the connector being + * broken. + * + * The stranger is never inserted anywhere. `composio_connections.user_id` is text with no foreign + * key and the brokered path touches no vault row, so asking as somebody unknown writes nothing this + * suite would have to clean up — which is the only reason a second person can appear here without + * a fixture. + */ +test("a Composio call by somebody who has not connected the app is refused even though a colleague has", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + // `user_asker` is connected to Gmail. Nobody else is. + await seedComposioGmail(database, store); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_stranger", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Never dialled, which is the half that matters: a call let through here is spent at the broker + // in a stranger's name, and the person asking sees somebody else's mailbox or somebody else's + // error. + expect(reached).toEqual([]); +}); + test("a Composio call whose url names no app is refused rather than falling back to the row id", async () => { const { store, database } = await freshStore(); const reached: string[] = []; From 4103f8b112b7b5bd5f993f633cb9d80447cac5ed Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:03:24 +0200 Subject: [PATCH 58/93] Take a brokered connection away when the person or the app goes Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 133 +++++++- server/tests/composio-connections.test.ts | 379 ++++++++++++++++++++++ 2 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 server/tests/composio-connections.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index bf50b722a..c19b1a5d0 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2006,6 +2006,12 @@ export function createPluginStore(options: PluginStoreOptions) { * * Revoked rather than deleted, because the vault keeps revoked rows for audit. * + * A THIRD KIND OF ACCESS THAT IS NOT A SECRET. A brokered app holds no per-person secret at all + * — Composio keeps the accounts and the deployment sends a user id — so the only thing standing + * between a person and their mailbox is a `composio_connections` row, and that table references + * nothing that would cascade it. Removing the app therefore left every one of them behind, and + * adding the app back turned them live again without anybody being asked. That row goes too. + * * The revokes go first. These are writes on two tables and the store exposes no transaction that * spans both, so the order decides what a failure between them leaves: revoke-then-delete leaves * a server whose secrets no longer work and which removing again will finish off, while @@ -2013,7 +2019,13 @@ export function createPluginStore(options: PluginStoreOptions) { */ async removeServer(serverId: string, by: string): Promise { const [existing] = await database - .select({ credentialId: mcpServers.credentialId }) + .select({ + credentialId: mcpServers.credentialId, + // Read so the brokered connections below can be keyed on the app the url names, which is + // the same key the call gate uses. See there for why the row id will not do. + provenance: mcpServers.provenance, + url: mcpServers.url, + }) .from(mcpServers) .where(eq(mcpServers.id, serverId)); @@ -2097,6 +2109,64 @@ export function createPluginStore(options: PluginStoreOptions) { }); } + /* + * The third kind of access, which is not a secret at all: everybody's brokered connection. + * + * CRITERION. Removing an app must leave nobody holding brokered access to it, so that adding + * it back again grants nothing until each person has consented afresh. + * + * REASON. `composio_connections` is the whole gate on a brokered call and it references + * nothing — not `mcp_servers`, not `users` — so nothing cascaded it and removing the app left + * every row standing. Two ordinary administrative acts, remove and add back, then restored + * everybody's access at a url the second act chose, with nobody asked again and no screen + * saying it had happened. Consent that reattaches by itself is not consent. + * + * KEYED ON THE APP THE URL NAMES, exactly as `connectionTokenFor` keys the gate. `row.id` is a + * display key and nothing holds it equal to the slug in the url, so a delete by id would clear + * some other app's connections, or none, for the very row shape that gate already refuses to + * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. + * + * Before the server row goes, for the reason the revokes above are: what a failure between + * two writes leaves has to be the recoverable half. A connection cleared with the app still + * present is fixed by removing it again; an app deleted with the connections standing is + * reachable by no operation at all, because the toolkit was only ever readable off its url. + */ + const toolkit = existing + ? accessFor(existing, catalogueEntry(serverId)).toolkit + : null; + + if (toolkit) { + const connected = await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)) + .returning({ userId: composioConnections.userId }); + + // Sorted, so two removals of the same app write their rows in the same order. + for (const connection of connected.sort((left, right) => + left.userId.localeCompare(right.userId), + )) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: by, + server: serverId, + owner: connection.userId, + // The same three-way distinction the vault loop above draws, and the same answer: an + // administrator took the whole app away and the person did nothing. + reason: "mcp_server_removed", + /* + * False, and said out loud. This closed the gate this deployment owns; the account + * the person connected is still connected at Composio, and only they or an operator + * of that broker can end it. A row implying otherwise would be worse than no row. + */ + vendorRevoked: false, + }, + }); + } + } + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); await recordAuditEvent(auditStore, { eventType: "configuration.changed", @@ -3041,6 +3111,11 @@ export function createPluginStore(options: PluginStoreOptions) { * The join rows go too, so the account pages stop claiming a connection this deployment can no * longer use. * + * AND THE BROKERED CONNECTIONS, which are neither a credential nor a join row. Composio holds + * the account, so there is no secret in the vault to find and the `composio_connections` row is + * itself the permission — the only thing deciding whether a call may go out as this person. + * Sweeping the vault alone therefore left that gate passing for somebody who had been removed. + * * NOT vendor-side revocation. That needs the OAuth client and the vendor's revoke endpoint, and * it belongs with disconnect. This is the half that stops us holding the secret; the grant at * Google outlives it until somebody revokes it there. Said plainly rather than implied, because @@ -3097,6 +3172,62 @@ export function createPluginStore(options: PluginStoreOptions) { .delete(mcpUserCredentials) .where(eq(mcpUserCredentials.userId, userId)); + /* + * Every app this person connected at the broker, where there is no secret to scan the vault + * for. + * + * CRITERION. After this returns, no brokered call may go out on this person's behalf. + * + * REASON. A brokered connection is not a credential: Composio holds the account and this + * deployment sends a user id, so the vault sweep above finds nothing and `composio_connections` + * is the entire gate. Reading only the vault therefore retired nothing for somebody whose only + * connector was brokered, reported that as a retirement, and left the `(toolkit, user_id)` gate + * passing for a person who no longer exists — their access outliving them, which is the first + * thing anybody asks about a per-person connector. The table's own docblock justifies its shape + * by this path, so the shape was carrying a promise nothing kept. + * + * FOUND HERE AND NOWHERE ELSE, which is what the missing foreign key buys. The row survives the + * `users` row precisely so this can still name what the person had after they are gone — the + * same argument the vault lookup above makes, from the side that has no vault row. It is also + * why the guard at the top of this method is load-bearing rather than defensive: `not null` + * admits the empty string, so a row at `(toolkit, "")` is legal, and retiring "nobody" must not + * be what deletes it. + * + * COUNTED, because the number is what "we removed their access" claims. Retiring twice stays + * quiet on its own: the rows are gone, so the second call deletes none. + */ + const brokered = await database + .delete(composioConnections) + .where(eq(composioConnections.userId, userId)) + .returning({ toolkit: composioConnections.toolkit }); + + // Sorted, so two retirements of the same person write their rows in the same order. + for (const connection of brokered.sort((left, right) => + left.toolkit.localeCompare(right.toolkit), + )) { + retired += 1; + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + // The app, which for a brokered connection is all the row records. The `mcp_servers` row + // it belongs to may have been removed already, and the connection outlives that too. + targetId: connection.toolkit, + payload: { + actor: by, + server: connection.toolkit, + owner: userId, + reason: "person_removed", + /* + * False here for a different reason than above. There, the grant at Google outlives our + * copy of the secret. Here there is no secret of ours at all: the account stays + * connected at Composio until somebody ends it there, and what this did was shut the + * only gate this deployment owns. + */ + vendorRevoked: false, + }, + }); + } + return { retired }; }, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts new file mode 100644 index 000000000..1a3d9eb8c --- /dev/null +++ b/server/tests/composio-connections.test.ts @@ -0,0 +1,379 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import type { ActionPolicy } from "../src/computer/policy"; +import type { + CredentialSecretReader, + CredentialStore, +} from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { + agents, + composioConnections, + mcpServers, + mcpTools, + pluginGrants, + users, +} from "../src/db/schema"; +import type { ComposioActions, ComposioResult } from "../src/plugins/composio"; +import { useComposioClient } from "../src/plugins/composio"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * What ends a brokered connection, and what the trail says when nobody was asking. + * + * `composio_connections` is the sole gate on a brokered call: the row `(toolkit, user_id)` is the + * whole of the permission, it points at no vault secret, and it references neither `users` nor + * `mcp_servers`. Nothing therefore cascades it away, which is deliberate — the row has to outlive + * the person so offboarding can still find it — and it means an explicit retirement is the ONLY + * thing that can ever end one. This file is about the two acts that must perform that retirement + * and about the trail they leave. + * + * WHY THIS FILE OWNS ITS IDS OUTRIGHT, AND SO NEEDS NO REFUSE-TO-RUN GUARD. + * `plugin-store.integration.test.ts` inserts at `gmail`, `notion` and `bot_helper` and refuses to + * run when a database already holds them: it asserts things about a real vendor's own action + * classification, so its ids are forced to be the spellings production uses, and a fixture at a + * forced id cannot coexist with a real row at that id. Nothing here asserts anything about a real + * vendor — `accessFor` answers `brokered` for ANY row whose provenance column says composio, and + * reads the app slug straight off the url — so every id below carries a run-unique suffix and every + * delete is keyed on one. That makes each row this file removes provably one it inserted, which is + * the property that guard buys the other way round, and it also lets this file run beside that one. + * + * The production deletes under test are keyed the same way: `removeServer` deletes by toolkit and + * `retireConnectionsFor` by user id, and both of those values are suite-scoped here, so neither can + * reach another run's rows either. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const suite = randomUUID().slice(0, 8); +/** The app: its `mcp_servers.id`, and the slug in its url, which is what a connection is keyed on. */ +const toolkit = `revocable-${suite}`; +const actionName = "APP_FETCH_ITEMS"; +const ref = `${toolkit}/${actionName}`; +const botId = `agent_revoke_${suite}`; +/** Somebody who connected the app. */ +const askerId = `user_asker_${suite}`; +/** Somebody who connected it and whose `users` row is then deleted out from under the connection. */ +const leaverId = `user_leaver_${suite}`; +const admin = "admin@openbot.local"; + +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; + +/** + * The vault, and every method loud. + * + * A brokered call reaches no credential at all — the deployment's Composio key belongs to the + * transport and never travels through the store — and neither of the removals under test has a + * secret of this suite's to retire, because no `mcp_user_token` is ever minted here. So any call to + * any of these means this file has started exercising something it does not claim to, and a silent + * stub would hide that. + * + * Typed as the interface rather than left to inference, so a method added to the vault fails here + * instead of at the assignment further down. This file is not covered by `tsc` today — `tests` is + * outside `server/tsconfig.json`'s `include` — which is exactly why the shape is stated rather than + * assumed. + */ +const credentialsStub: CredentialSecretReader & CredentialStore = { + readSecret: async () => { + throw new Error("a brokered call reads no credential"); + }, + create: async () => { + throw new Error("this suite does not write credentials"); + }, + updateSecret: async () => { + throw new Error("this suite does not write credentials"); + }, + rotate: async () => { + throw new Error("this suite does not write credentials"); + }, + revoke: async () => { + throw new Error("this suite mints no credential to revoke"); + }, + isLive: async () => { + throw new Error("this suite holds no credential to ask about"); + }, + findLiveByKey: async () => { + throw new Error("this suite holds no credential to ask about"); + }, +}; + +/** + * A store over the real database, keeping every event it writes. + * + * Recorded ALONGSIDE the real insert rather than instead of it: the payloads are what these tests + * assert about, and a store whose audit insert never touched the database would not be exercising + * the one it has. + */ +const events: Parameters["insert"]>[0][] = + []; +const persisting = createAuditStore(database); +const auditStore = { + insert: async (event: (typeof events)[number]) => { + events.push(event); + await persisting.insert(event); + }, +}; + +const store = createPluginStore({ + database, + auditStore, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, +}); + +/** Every action Composio was asked to run, so "was this call made" is an assertion and not a guess. */ +const reached: string[] = []; + +const answered: ComposioResult = { data: {}, error: null, successful: true }; + +/** + * A client that answers everything, so a refusal in these tests is always this deployment's. + * + * The vendor is a process-wide registry, so `afterEach` takes it back out: a stub outliving its test + * would be answering another file's calls. + */ +function useAnsweringClient(actions: Partial = {}) { + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return answered; + }, + ...actions, + }); +} + +/** Only this run's rows, and every one of them keyed on an id this run invented. */ +async function clean() { + await database.delete(pluginGrants).where(eq(pluginGrants.agentId, botId)); + await database.delete(agents).where(eq(agents.id, botId)); + await database.delete(mcpTools).where(eq(mcpTools.serverId, toolkit)); + await database.delete(mcpServers).where(eq(mcpServers.id, toolkit)); + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)); + await database.delete(users).where(inArray(users.id, [askerId, leaverId])); +} + +/** The app's row and its one granted action. Separated from the Bot, so a re-add can reuse the Bot. */ +async function addApp() { + await database.insert(mcpServers).values({ + id: toolkit, + title: "Revocable App", + vendor: "Composio", + url: `composio://${toolkit}`, + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: toolkit, + name: actionName, + description: "Fetch some items.", + effect: "read", + version: "20260903_00", + }); + await store.grant("mcp", ref, botId, admin); +} + +/** The app, a Bot holding its one action, and optionally somebody who has connected it. */ +async function seedApp(options: { connect?: boolean } = {}) { + await database.insert(agents).values({ + id: botId, + name: "Helper", + type: "built_in", + configuration: {}, + }); + await addApp(); + if (options.connect !== false) { + await database + .insert(composioConnections) + .values({ toolkit, userId: askerId }); + } +} + +/** What this deployment still believes somebody has connected. */ +async function connectedToolkitsFor(userId: string): Promise { + const rows = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, userId)); + return rows.map((row) => row.toolkit); +} + +function recordedOfType(eventType: string) { + return events.filter((event) => event.eventType === eventType); +} + +// Cleaning BEFORE each test as well as after the run, so a run that dies halfway leaves the next +// one nothing to trip over. +beforeEach(async () => { + await clean(); + events.length = 0; + reached.length = 0; +}); + +afterEach(() => useComposioClient(null)); + +afterAll(async () => { + await clean(); +}); + +/** + * OFFBOARDING. The act an administrator is told removes somebody's access. + * + * The call is made first, so what follows is an assertion about the retirement rather than about the + * fixture. Reaching the vendor a second time would be the person's mailbox being opened after they + * were removed. + */ +test("offboarding somebody retires the app they connected, and the next call is refused", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); + + const { retired } = await store.retireConnectionsFor(askerId, admin); + + // Counted, because the number is what "we removed their access" claims. Reporting the vault's + // tally alone would say nothing was retired for somebody whose only connector was brokered. + expect(retired).toBe(1); + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: askerId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([actionName]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0].payload).toMatchObject({ + actor: admin, + server: toolkit, + owner: askerId, + // An administrator removing somebody, never somebody changing their own mind. And the account + // at the broker is still connected: this closed the gate, it did not revoke anything at + // Composio. + reason: "person_removed", + vendorRevoked: false, + }); +}); + +/** + * THE GATE, AFTER THE PERSON IS GONE. + * + * `composio_connections.user_id` carries no foreign key by design, so deleting somebody's `users` + * row leaves their connection standing — and the gate reads nothing but `(toolkit, user_id)`, so it + * goes on passing for an id no person answers to. That is the state offboarding exists to end, and + * it is the one the vault-based retirement cannot reach: there is no secret here to scan for, + * because Composio holds the account. + */ +test("a connection whose person is already deleted is retired, and stops passing the gate", async () => { + await seedApp({ connect: false }); + useAnsweringClient(); + + await database + .insert(users) + .values({ id: leaverId, email: `${leaverId}@example.com`, name: "Leaver" }); + await database + .insert(composioConnections) + .values({ toolkit, userId: leaverId }); + await database.delete(users).where(eq(users.id, leaverId)); + + // The design fact this rests on: the row outlives the person, which is what leaves anything to + // find. Asserted rather than assumed, because the retirement below is pointless without it. + expect(await connectedToolkitsFor(leaverId)).toEqual([toolkit]); + + const { retired } = await store.retireConnectionsFor(leaverId, admin); + expect(retired).toBe(1); + expect(await connectedToolkitsFor(leaverId)).toEqual([]); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: leaverId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([]); +}); + +/** Retiring twice is something an administrator may legitimately do, and the second time is quiet. */ +test("retiring the same person twice retires nothing the second time", async () => { + await seedApp(); + useAnsweringClient(); + + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(1); + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(0); +}); + +/** + * THE ANONYMOUS ACTOR OWNS NOTHING, and `notNull` does not exclude the empty string, so a row at + * `(toolkit, "")` is legal. Retiring "nobody" must not be what deletes it — that would be an + * unattributed offboarding reaching a row it cannot possibly own. + */ +test("retiring nobody retires nothing and leaves the anonymous row alone", async () => { + await seedApp({ connect: false }); + await database.insert(composioConnections).values({ toolkit, userId: "" }); + + expect((await store.retireConnectionsFor("", admin)).retired).toBe(0); + expect(await connectedToolkitsFor("")).toEqual([toolkit]); +}); + +/** + * REMOVING THE APP. The second act that has to end a brokered connection. + * + * Nothing else can: the table references `mcp_servers` no more than it references `users`, so the + * rows simply stand there once the app's row is gone. + */ +test("removing the app takes every brokered connection to it", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + + await store.removeServer(toolkit, admin); + + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0].payload).toMatchObject({ + actor: admin, + server: toolkit, + owner: askerId, + // An administrator took the whole app away and the person did nothing. Distinct from both + // "they disconnected" and "they were removed", which is what an auditor is trying to tell apart. + reason: "mcp_server_removed", + vendorRevoked: false, + }); +}); + +/** + * CONSENT MUST NOT REATTACH. + * + * Removing an app and adding it back is two ordinary administrative acts. If the connection rows + * survive them, the second act silently restores everybody's brokered access without anybody being + * asked again — and the only visible difference between an app nobody has connected and an app + * everybody is still connected to is whether a call goes out. + */ +test("adding the app back does not restore a connection nobody re-granted", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); + + await store.removeServer(toolkit, admin); + // The same app at the same id, added again. Only the server and its action: the Bot's grant + // survived the removal on its own, which is a separate defect about `plugin_grants` and not this + // one. + await addApp(); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: askerId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([actionName]); +}); From d852a7a16c798c08ac850e04a1bad0742c724b39 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:04:02 +0200 Subject: [PATCH 59/93] Say who an audit row is about when the answer is not a person Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 65 ++++++++++++++++++++-- server/tests/composio-connections.test.ts | 67 +++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index c19b1a5d0..61db3b274 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -343,6 +343,27 @@ export function unlistedAdvertisedTools( const iso = (value: Date | string | null): string | null => value === null ? null : value instanceof Date ? value.toISOString() : value; +/** + * The two things an actor field says when the actor is not a person, and they are not the same + * thing. + * + * CRITERION. A field whose purpose is to name who did something must never be written as the empty + * string. An absent field reads as absent; `""` reads as a value, so a reader grouping the trail by + * actor gets a person called nothing, and every count of "acts by X" is quietly wrong about them. + * + * `deployment` is a positive answer: nobody was asking because the deployment itself acted — a + * shared credential, a public endpoint, a refresh it ran on its own behalf immediately after an app + * was added. `unattributed` is the opposite, and the distinction is the whole point of having two: + * something happened that SHOULD have had a person behind it and this deployment could not say who. + * `identifyActor` answers `{ id: "" }` for exactly that, and the run is then refused — which is + * precisely the moment the trail is worth reading, so it must not be the moment it goes blank. + * + * Neither is an address, so neither can collide with a user id: every actor written here otherwise + * is `users.id` or the email a session resolved to. + */ +const DEPLOYMENT_ACTOR = "deployment"; +const UNATTRIBUTED_ACTOR = "unattributed"; + /** * Whose account this call went out as, for the trail. * @@ -350,9 +371,15 @@ const iso = (value: Date | string | null): string | null => * had no answer for a Composio app — the entry is null, so it fell through to `deployment` for a call * that ran in one person's own mailbox, which is the trail being wrong about the one thing a * per-person connector exists for. + * + * A person-reached server with no actor is `unattributed` and never `deployment`: the call did not + * go out on a shared credential, it did not go out at all, and naming the deployment would assert + * an attribution that never happened. See {@link DEPLOYMENT_ACTOR}. */ const reachedAsFor = (access: ServerAccess, actorId: string): string => - access.reachedAs === "person" ? actorId : "deployment"; + access.reachedAs === "person" + ? actorId || UNATTRIBUTED_ACTOR + : DEPLOYMENT_ACTOR; /** * Where this server actually is, when the stored row and the catalogue disagree. @@ -2202,6 +2229,23 @@ export function createPluginStore(options: PluginStoreOptions) { ): Promise<{ tools: number }> { const { row, entry, access } = await requireServer(serverId); + /* + * Who the trail says asked for this listing, which is not the same value as who to list AS. + * + * CRITERION. The two audit rows below must never name an actor of `""`. + * + * REASON. `actorId` does double duty: it selects the person's credential where listing needs + * one, and it is copied into those rows. The add paths pass neither, deliberately — nobody can + * have connected an app in the second it is added, and the comment above this method says why + * requiring one there was wrong. So the absence is permanent and correct for the credential, + * and meaningless for the trail, which was left writing `actor: ""` on every row an add + * produced. The deployment refreshing on its own behalf is a real answer and `reachedAs` + * already spells it that way; see {@link DEPLOYMENT_ACTOR}. Held separately rather than + * defaulting the parameter, because defaulting it would hand `connectionTokenFor` a person + * called "deployment" to look a grant up by. + */ + const auditActor = actorId || DEPLOYMENT_ACTOR; + // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. const transport = transportFor(access.transport); @@ -2445,7 +2489,7 @@ export function createPluginStore(options: PluginStoreOptions) { targetType: "mcp_server", targetId: serverId, payload: { - actor: actorId, + actor: auditActor, change: "grants_not_advertised", server: serverId, // The refs, because that is what a grant is keyed on and what an administrator revokes. @@ -2475,7 +2519,7 @@ export function createPluginStore(options: PluginStoreOptions) { targetType: "mcp_server", targetId: serverId, payload: { - actor: actorId, + actor: auditActor, change: "unlisted_tools_advertised", server: serverId, tools: unlisted, @@ -3287,6 +3331,17 @@ export function createPluginStore(options: PluginStoreOptions) { throw new PluginRefusedError(`${input.ref} is not a tool.`, null); } + /* + * Who the trail says made this call, which is not what the call is made AS. + * + * `input.actorId` stays the value every gate is decided on, and the empty string must go on + * matching no grant and no connection anywhere. This is only what the row says: a run nobody + * could be attributed to is `unattributed` rather than blank, on the criterion at + * {@link DEPLOYMENT_ACTOR}, and never `deployment` — a run this deployment could not put a + * name to is not the deployment having acted. + */ + const auditActor = input.actorId || UNATTRIBUTED_ACTOR; + const decision = await this.decide("mcp", input.ref, input.botId); if (!decision.allowed) { await recordAuditEvent(auditStore, { @@ -3295,7 +3350,7 @@ export function createPluginStore(options: PluginStoreOptions) { targetId: input.ref, ...(input.initiator ? { initiator: input.initiator } : {}), payload: { - actor: input.actorId, + actor: auditActor, bot: input.botId, server: serverId, tool: toolName, @@ -3402,7 +3457,7 @@ export function createPluginStore(options: PluginStoreOptions) { * the row goes down once, after the outcome exists. */ const decided = { - actor: input.actorId, + actor: auditActor, bot: input.botId, server: serverId, tool: toolName, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 1a3d9eb8c..59881107c 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -377,3 +377,70 @@ test("adding the app back does not restore a connection nobody re-granted", asyn ).rejects.toThrow(/have not connected/i); expect(reached).toEqual([actionName]); }); + +/** + * THE TRAIL, WHERE NOBODY WAS ASKING. + * + * An empty string in a field whose purpose is to name who did something is worse than an absent + * field: it reads as a value, and a reader counting rows by actor gets a person called "". + * + * `reachedAs` and `actor` are the two on this row, and both are the run's actor verbatim. A brokered + * app is reached AS THE PERSON, so a run nobody could be attributed to has no name to put in either + * — and the refusal is recorded, which is exactly when the trail matters. + */ +test("an unattributed run is recorded as unattributed rather than as a blank", async () => { + await seedApp(); + useAnsweringClient(); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: "" }), + ).rejects.toThrow(/not attributed to anybody/i); + expect(reached).toEqual([]); + + const failed = recordedOfType("mcp.call_failed"); + expect(failed).toHaveLength(1); + expect(failed[0].payload).toMatchObject({ + actor: "unattributed", + reachedAs: "unattributed", + }); + // Not "deployment" either: this call did not go out on a shared credential, it did not go out at + // all, and saying the deployment reached the app would assert an attribution that never happened. + expect(failed[0].payload.reachedAs).not.toBe("deployment"); +}); + +/** + * THE TRAIL, WHERE THE DEPLOYMENT WAS THE ONE ACTING. + * + * `refreshTools` defaults its actor to the empty string, and `addServer` and `addCustomServer` both + * take that default — deliberately, because that argument doubles as the credential to list with and + * nobody can have connected an app in the moment it is added. So the absence is real and permanent, + * and what the trail owes a reader is the distinction: not a person, and not nobody either, but the + * deployment refreshing on its own behalf. `reachedAs` already spells that "deployment". + */ +test("the refresh that follows an add is attributed to the deployment", async () => { + await seedApp(); + // A different action, so the granted one is left held and not advertised — which is the audit row + // under test. + useAnsweringClient({ + listActions: async () => [ + { + slug: "APP_SOMETHING_ELSE", + description: "Not the one anybody holds.", + version: "20260903_00", + }, + ], + }); + + // No actor, which is exactly what the add path passes. + await store.refreshTools(toolkit); + + const stranded = events.filter( + (event) => + (event.payload as { change?: string }).change === "grants_not_advertised", + ); + expect(stranded).toHaveLength(1); + expect(stranded[0].payload).toMatchObject({ + actor: "deployment", + refs: [ref], + }); +}); From c1e70d44e4364b6bcf0c80615e81efa7450ac852 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:54:22 +0200 Subject: [PATCH 60/93] Throw when no app was named and when no client was installed, rather than listing nothing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 35 ++++++++++++++++++++++--- server/tests/composio-transport.test.ts | 33 +++++++++++++++++++---- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 6f53174bd..9ecba6b62 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -98,8 +98,10 @@ let installed: ComposioActions | null = null; * * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that * installs a stub has to be able to take it back out. It is also the unconfigured state — a - * deployment with no Composio key installs nothing, and every function here answers emptily or - * refuses rather than failing, so an app nobody configured is absent rather than broken. + * deployment with no Composio key installs nothing. What that state produces is not an empty answer: + * {@link listTools} THROWS and {@link callTool} refuses, both saying which of the two it is, because + * an empty listing is indistinguishable from an app that advertises nothing and would be committed + * as one. */ export function useComposioClient(client: ComposioActions | null): void { installed = client; @@ -162,12 +164,39 @@ export function effectOf(tags: readonly string[] | undefined): { * turned around: an empty list is what an app with no actions looks like, so answering emptily would * report a success and strand every grant. What throws is a sentence, never a vendor object. See the * catch below. + * + * AND SO IS A LISTING NOBODY WAS ASKED FOR, which is the same criterion applied one step earlier. + * `[]` from a `listTools` means, in `mcp.ts`, `google-drive-rest.ts` and `builtin-routines.ts` + * alike, "the vendor was asked and advertises no actions" — and `refreshTools` commits that as a + * healthy refresh. This function used to answer `[]` for a url naming no app and for a deployment + * with no client installed, neither of which involved asking anybody, and the commit deleted every + * `mcp_tools` row for the app: the recorded `effect`, `destructive` and, fatally, `version`, which + * `callTool` refuses to run without and which only a listing can put back. So the two "asked + * nobody" cases throw, and they throw SEPARATELY, because one sends an operator to this + * deployment's configuration and the other to the row's url. */ export async function listTools(connection: { url: string; }): Promise { const toolkit = toolkitOf(connection.url); - if (!toolkit || !installed) return []; + if (!toolkit) { + throw new Error( + `${connection.url} does not name a Composio app, so nothing was asked what it offers. A row reached through this transport is one whose provenance says composio, and its url has to be composio:// followed by an app slug; correct the url on the Plugins page.`, + ); + } + if (!installed) { + /* + * A STATE, NOT A FAULT, and the sentence has to read as one. + * + * Nothing under `server/src` installs a Composio client — see {@link useComposioClient} — so + * this is what every Composio refresh on every real deployment answers today, by design and + * not by accident. An operator who reads it as a crash goes looking for a broken vendor; what + * they need to know is that the connector is not wired up here and that nothing was lost. + */ + throw new Error( + `Composio is not configured for this deployment, so nothing could be asked what ${toolkit} offers. That is the expected answer until a Composio client is installed at startup, and the actions already recorded for this app are kept rather than cleared.`, + ); + } let actions: ComposioAction[]; try { diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 3090f9d8e..070d9adf9 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -222,13 +222,36 @@ describe("listing an app's actions", () => { expect(tool?.effect).toBe("write"); }); - test("listing a url that names no app is empty rather than an error", async () => { - useComposioClient(recording().client); - expect(await listTools({ url: "https://example.com" })).toEqual([]); + test("a listing nobody was asked for throws rather than answering empty", async () => { + // `[]` means "the vendor was asked and advertises none" everywhere else in this codebase, and + // `refreshTools` commits it as a healthy refresh. No client installed is the SHIPPED state — + // nothing under `server/src` calls `useComposioClient` — so `[]` here was the only answer a + // real Composio refresh could produce, and committing it deleted every recorded action. + const listing = listTools({ url: "composio://gmail" }); + + await expect(listing).rejects.toThrow( + /not configured for this deployment/i, + ); + + const thrown = (await listing.catch((error: unknown) => error)) as Error; + expect(thrown.message).toContain("gmail"); + // Not a crash report. No deployment installs a client yet, so an operator reading this has to + // recognise a state rather than go hunting for a fault. + expect(thrown.message).toMatch(/expected/i); }); - test("listing with no client installed is empty rather than a crash", async () => { - expect(await listTools({ url: "composio://gmail" })).toEqual([]); + test("a url that names no app throws about the url, not about the client", async () => { + useComposioClient(recording().client); + + const listing = listTools({ url: "https://example.com" }); + + // The two refusals send an operator to different places — one to this deployment's + // configuration, one to the row — so they must not share a sentence. + await expect(listing).rejects.toThrow(/does not name a Composio app/i); + + const thrown = (await listing.catch((error: unknown) => error)) as Error; + expect(thrown.message).not.toMatch(/not configured/i); + expect(thrown.message).toContain("https://example.com"); }); test("a listing the vendor's own schema rejects throws a sentence, not a Zod dump", async () => { From bb5f92a04f2548b876c456ee3c1411ab1fd200c2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:08 +0200 Subject: [PATCH 61/93] Ask for the whole listing, and refuse a page too full to know it was the whole listing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 53 ++++++++++++++++++++++++- server/tests/composio-transport.test.ts | 44 ++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 9ecba6b62..346561d95 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -35,6 +35,28 @@ import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; */ export const VERSION_ARG = "__version"; +/** + * How many actions one listing asks for, which is as many as this SDK can be made to answer with. + * + * A NUMBER RATHER THAN NO NUMBER, because omitting it is not "no opinion". Composio's page defaults + * to 20 and Gmail publishes 63 actions, so an omitted limit truncates — and worse, it NARROWS: + * `getRawComposioTools` sets `important=true` whenever the query named toolkits and gave no limit, + * no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so the short + * answer is a filtered one and nothing in it says a filter was applied. Passing a limit is what + * turns the flag off. + * + * 1000 BECAUSE THAT IS THE CEILING, not because it is generous. The REST parameter documents "max + * allowed is 1000" (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:441-444`), and the + * core SDK exposes no way to go past it: `ToolListParamsSchema` has no cursor field, and + * `getRawComposioTools` reads `tools.items` and drops the response's `next_cursor`. So one page at + * the ceiling is not a page — it is the whole listing, and the only listing expressible here. + * + * Which is why {@link listTools} refuses a page that came back FULL. At the ceiling a complete + * answer and a truncated one are the same array, and there is no second request that could tell + * them apart. + */ +export const LISTING_LIMIT = 1000; + /** One action, as much of Composio's listing as anything here reads. */ export type ComposioAction = { slug: string; @@ -82,7 +104,19 @@ export type ComposioResult = { * resolution and an exception have to be read. */ export type ComposioActions = { - listActions(toolkit: string): Promise; + /** + * Every action of one app, for a page the CALLER has to name. + * + * `page` is required rather than optional, and that is the whole point of it being here. The + * previous signature took the toolkit alone, so an adapter had nothing to pass a limit through + * and the SDK's default applied — 20 rows, silently narrowed to the vendor's "important" subset. + * A required argument makes the narrowed listing a thing a caller has to ask for on purpose + * instead of a thing they get by leaving something out. See {@link LISTING_LIMIT}. + */ + listActions( + toolkit: string, + page: { limit: number }, + ): Promise; execute( slug: string, userId: string, @@ -200,7 +234,7 @@ export async function listTools(connection: { let actions: ComposioAction[]; try { - actions = await installed.listActions(toolkit); + actions = await installed.listActions(toolkit, { limit: LISTING_LIMIT }); } catch (error) { /* * THROWN, NOT ANSWERED EMPTY, and with a sentence rather than the vendor's raw object. @@ -219,6 +253,21 @@ export async function listTools(connection: { throw new Error(listingSentence(toolkit, error), { cause: error }); } + if (actions.length >= LISTING_LIMIT) { + /* + * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. + * + * `LISTING_LIMIT` is the largest page the vendor's REST parameter allows, and the core SDK + * offers no cursor to ask for a second one. So an app with exactly that many actions and an app + * with more of them answer identically here. Committed as complete, the second one has every + * action past the cut deleted from `mcp_tools` under a refresh that reported success — the same + * loss the empty answer used to cause, arriving by a different route. + */ + throw new Error( + `Composio answered with ${actions.length} actions for ${toolkit}, which is the largest page this deployment's @composio/core can ask for, so there may be more that it cannot see. The actions already recorded are kept rather than replaced by a listing that might be a fragment.`, + ); + } + return actions.map((action) => { const { effect, destructive } = effectOf(action.tags); return { diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 070d9adf9..cf4ad0cbc 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -3,6 +3,7 @@ import { type ComposioActions, callTool, effectOf, + LISTING_LIMIT, listNeedsCredential, listTools, toolkitOf, @@ -180,6 +181,49 @@ describe("listing an app's actions", () => { expect(listNeedsCredential).toBe(false); }); + test("the listing asks for a page, and for one big enough to be the whole list", async () => { + const asked: unknown[] = []; + useComposioClient( + recording({ + listActions: async (toolkit, page) => { + asked.push({ toolkit, page }); + return [GMAIL_READ]; + }, + }).client, + ); + + await listTools({ url: "composio://gmail" }); + + // Composio's default page is 20 and Gmail publishes 63 actions, so an omitted limit truncates. + // It also NARROWS: `getRawComposioTools` auto-applies `important=true` when no limit, no tags + // and no search were given (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), and + // nothing in the short answer says a filter was applied. Asking for a page is therefore not an + // optimisation, and the seam must not let a caller forget to. + expect(asked).toEqual([ + { toolkit: "gmail", page: { limit: LISTING_LIMIT } }, + ]); + }); + + test("a listing that filled the biggest page the SDK can ask for is not called complete", async () => { + useComposioClient( + recording({ + listActions: async () => + Array.from({ length: LISTING_LIMIT }, (_unused, index) => ({ + ...GMAIL_READ, + slug: `GMAIL_ACTION_${index}`, + })), + }).client, + ); + + // `ToolListParamsSchema` accepts no cursor and `getRawComposioTools` drops the response's + // `next_cursor`, so one page at the API's stated maximum is the largest listing expressible + // through this SDK. A page that came back full is therefore indistinguishable from a truncated + // one, and committing it would delete every action past the cut while reporting a success. + await expect(listTools({ url: "composio://gmail" })).rejects.toThrow( + /there may be more/i, + ); + }); + test("an action arrives with its schema, its effect and its version", async () => { const { client } = recording({ listActions: async (toolkit) => { From b2b16ba35a68ea3dfd4b54174974fd8940bd0aa6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:58:11 +0200 Subject: [PATCH 62/93] Stop offering a model an action whose file argument only Composio's own uploader can produce Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 101 +++++++++++++++++++++--- server/tests/composio-transport.test.ts | 76 ++++++++++++++++++ 2 files changed, 165 insertions(+), 12 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 346561d95..68ebee6e8 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -187,12 +187,65 @@ export function effectOf(tags: readonly string[] | undefined): { return { effect: "write", destructive: false }; } +/** A JSON Schema node, or null for anything that is not one. */ +function schemaNode(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Whether an action asks for a file, anywhere in its schema. + * + * `file_uploadable` is Composio's own extension keyword, and one of the few the SDK's + * `JSONSchemaPropertySchema` whitelists rather than strips (`@composio/core` 0.18.1, + * `src/types/tool.types.ts:89`) — so unlike most of what the vendor publishes, this one is still + * here to be read. See {@link listTools} for what is done with the answer. + * + * WALKED, NOT LOOKED UP. Composio toolkits routinely put the flag behind a `$ref`/`$defs` + * indirection or inside an `anyOf` variant, which is why the vendor's own predicate recurses + * through both (`src/utils/modifiers/FileToolModifier.utils.neutral.ts:77-134`). A check that read + * only the top level of `properties` would answer false for every ref-based schema, which is the + * majority of the ones that carry a file. + * + * The keys walked are the composition keywords `ParametersSchema` and `JSONSchemaPropertySchema` + * actually keep, and no others: a key those two strip cannot be present to be walked. + */ +function stagesAFile(schema: unknown): boolean { + const node = schemaNode(schema); + if (!node) return false; + if (node.file_uploadable === true) return true; + + for (const key of [ + "properties", + "patternProperties", + "$defs", + "definitions", + ]) { + const children = schemaNode(node[key]); + if (children && Object.values(children).some(stagesAFile)) return true; + } + + for (const key of ["anyOf", "oneOf", "allOf", "items", "not"]) { + const branch = node[key]; + if ( + Array.isArray(branch) ? branch.some(stagesAFile) : stagesAFile(branch) + ) { + return true; + } + } + + return false; +} + /** * Every action this app publishes, in the shape a `tools/list` answer has, plus what we know about it. * * An action with no schema is still listed, with an open one. The vendor is the right party to reject a * bad argument, and an action silently missing from the list reads to an administrator as an app that - * does not have it. + * does not have it. The one exception is an action that asks for a FILE, which is dropped — see the + * criterion beside the filter below, and note that it turns on the action being uncallable rather + * than on its schema being unfamiliar. * * A listing that could not be read at all is a THROW rather than an empty list, for the same reason * turned around: an empty list is what an app with no actions looks like, so answering emptily would @@ -268,17 +321,41 @@ export async function listTools(connection: { ); } - return actions.map((action) => { - const { effect, destructive } = effectOf(action.tags); - return { - name: action.slug, - description: action.description ?? "", - inputSchema: action.inputParameters ?? {}, - effect, - destructive, - ...(action.version ? { version: action.version } : {}), - }; - }); + /* + * AN ACTION IS OFFERED ONLY IF A MODEL COULD ACTUALLY FILL IN ITS ARGUMENTS. + * + * A `file_uploadable` parameter fails that. Under the SDK's default file handling — the flag is + * `dangerouslyAllowAutoUploadDownloadFiles` and it is off unless a client asks for it + * (`src/models/Tools.ts:136`, `:242-248`) — the parameter reaches the model as the vendor's + * internal staging descriptor, `{ name, mimetype, s3key }`. An `s3key` is issued by an upload to + * Composio's bucket. Nothing in this deployment performs one, and a model has no way to obtain + * one, so the only value it can produce is invented and the vendor's staging lookup rejects the + * call. The SDK says as much itself in the warning it logs on that path (`:349-366`). + * + * WHY THIS IS NOT THE SAME AS THE SCHEMALESS ACTION ABOVE, which is deliberately still offered. + * There the vendor is the right party to reject a bad argument, and the action might well + * succeed. Here it cannot: every call is a rejection, and an advertised action that can only + * fail is worse than an absent one, because an administrator grants it, the audit trail records + * attempts against it, and the model spends turns retrying with a different invented key. + * + * ENABLING AUTO-UPLOAD WOULD NOT FIX IT EITHER, which is why the answer is not "turn the flag + * on". That flag collapses the parameter to `{ type: 'string', format: 'path' }` — a promise + * that the SDK will read a local path off this server's disk. A model naming a server-side path + * is a worse offer than one naming a bucket key, not a better one. + */ + return actions + .filter((action) => !stagesAFile(action.inputParameters)) + .map((action) => { + const { effect, destructive } = effectOf(action.tags); + return { + name: action.slug, + description: action.description ?? "", + inputSchema: action.inputParameters ?? {}, + effect, + destructive, + ...(action.version ? { version: action.version } : {}), + }; + }); } /** diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index cf4ad0cbc..26949cbfb 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -248,6 +248,82 @@ describe("listing an app's actions", () => { ]); }); + test("an action that stages a file is not offered at all", async () => { + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + tags: ["createHint"], + version: "20260903_00", + inputParameters: { + type: "object", + properties: { + recipient: { type: "string" }, + // What the SDK hands on under its default file handling: the vendor's own staging + // descriptor, untouched. `dangerouslyAllowAutoUploadDownloadFiles` is off unless a + // client asks for it (`src/models/Tools.ts:136`, `:242-248`), and only that flag + // collapses the shape. An `s3key` is issued by an upload nothing here performs. + attachment: { + type: "object", + file_uploadable: true, + properties: { + name: { type: "string" }, + mimetype: { type: "string" }, + s3key: { type: "string" }, + }, + }, + }, + required: ["recipient", "attachment"], + }, + }, + ], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + // Dropped rather than offered with a field the model can only invent. Offering it guarantees a + // hallucinated key and a rejection at the vendor's staging lookup, and a grant recorded against + // a name that can never work. + expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); + }); + + test("a file parameter reached through $defs and a variant is found too", async () => { + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_GET_ATTACHMENT", + version: "20260903_00", + inputParameters: { + type: "object", + properties: { body: { $ref: "#/$defs/upload" } }, + $defs: { + upload: { + anyOf: [ + { type: "null" }, + { type: "string", file_uploadable: true }, + ], + }, + }, + }, + }, + ], + }).client, + ); + + // Composio toolkits routinely express the flag through a `$ref`/`$defs` indirection, which is + // why the SDK's own predicate walks `$defs` and every composed variant + // (`src/utils/modifiers/FileToolModifier.utils.neutral.ts:77-134`). A walk that stopped at + // `properties` would answer false for every ref-based schema and offer it anyway. + const listed = await listTools({ url: "composio://gmail" }); + expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); + }); + test("an action with no schema is still listed, with an open one", async () => { useComposioClient( recording({ From 9f5c9de67962eba91746e355e4608d99fa869ee0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:59:31 +0200 Subject: [PATCH 63/93] Read one app slug out of a connection url, or read no app at all Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 21 +++++++++++++++++++-- server/tests/composio-transport.test.ts | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 68ebee6e8..c6bb4d8cd 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -151,12 +151,29 @@ export const listNeedsCredential = false; * is the field every transport already gets and `effectiveUrl` already owns. Null for anything that is * not one of ours, so a misrouted connection lists nothing instead of asking Composio about a * hostname. + * + * ONE SLUG OR NOTHING, and the strictness is the security property rather than tidiness. This answer + * becomes `ServerAccess.toolkit` (`./access`), which is the name the brokered gate looks a person's + * row up by in `composio_connections` — so a url read loosely is somebody's connection to one app + * satisfying a call against another. Whatever follows the scheme has to be a slug and nothing else: + * `composio://gmail/messages` used to answer `"gmail/messages"`, taking a path segment for an app. + * + * TRIMMED BEFORE THE SLASHES COME OFF, because the other order does not work. `composio://gmail/ ` + * ran the strip against a string whose last character was a space, so the slash was not at the end, + * nothing matched, and the trim then produced `"gmail/"`. + * + * The character class is deliberately not case-folded. `composio_connections.toolkit` documents the + * column as lower case and this function does not lower-case what it returns; that mismatch is a + * separate known issue, and matching case-insensitively here keeps this change to the shape of the + * url rather than quietly settling it. */ +const TOOLKIT_SLUG = /^[A-Za-z0-9_-]+$/; + export function toolkitOf(url: string): string | null { const prefix = "composio://"; if (!url.startsWith(prefix)) return null; - const slug = url.slice(prefix.length).replace(/\/+$/, "").trim(); - return slug === "" ? null : slug; + const slug = url.slice(prefix.length).trim().replace(/\/+$/, ""); + return TOOLKIT_SLUG.test(slug) ? slug : null; } /** diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 26949cbfb..d6c65632f 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -89,6 +89,26 @@ describe("which app a connection names", () => { expect(toolkitOf("composio://")).toBeNull(); expect(toolkitOf("")).toBeNull(); }); + + test("anything past the app slug means the url does not name one app", () => { + // This answer is the app a person's connection is checked against — `accessFor` puts it on + // `ServerAccess.toolkit` (`access.ts:133`) and the brokered gate looks `composio_connections` + // up by it. A url this function reads loosely is a check performed against the wrong app, so + // anything it cannot read as exactly one slug has to be no app rather than a best guess. + expect(toolkitOf("composio://gmail/messages")).toBeNull(); + expect(toolkitOf("composio://gmail?scope=read")).toBeNull(); + expect(toolkitOf("composio://gmail#inbox")).toBeNull(); + expect(toolkitOf("composio://gmail slack")).toBeNull(); + }); + + test("surrounding space is taken off before the trailing slash, not after", () => { + // The strip ran first and the trim second, so a slash that was not the last character survived + // it: `composio://gmail/ ` answered `"gmail/"`, which matches no row in `composio_connections` + // and is not the app anybody meant. + expect(toolkitOf("composio://gmail/ ")).toBe("gmail"); + expect(toolkitOf("composio://gmail ")).toBe("gmail"); + expect(toolkitOf("composio://google_drive//")).toBe("google_drive"); + }); }); describe("what a label means", () => { From 10b6bf0166853f71e97f8709ea94897f2ebcc550 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:00:42 +0200 Subject: [PATCH 64/93] Describe the client seam as unwired, and make the version remedy conditional on the vendor Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 35 ++++++++++++++++++------- server/tests/composio-transport.test.ts | 9 +++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index c6bb4d8cd..f69faa0df 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -19,8 +19,14 @@ import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; * * It implements the same interface as the other three, as module-level exports, because that is the * shape {@link ./transport} resolves: a `TransportKind` maps to a MODULE. Which is also why the client - * arrives through {@link useComposioClient} rather than a constructor — the registry is built at - * import time, long before `index.ts` has configuration. + * would have to arrive through a setter rather than a constructor — the registry is built at import + * time, long before anything has read configuration. {@link useComposioClient} is that setter. + * + * NOTHING IN THE SHIPPED PRODUCT CALLS IT. There is no adapter under `server/src`: the only caller + * is the test suite. So on every real deployment `installed` is null, which is a state this module + * is written for rather than an outage — {@link listTools} throws a sentence saying so and + * {@link callTool} refuses with one. Read every mention of "the client" below as a description of + * the seam an adapter would plug into, not of wiring that exists. */ /** @@ -75,8 +81,8 @@ export type ComposioAction = { * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells all three of these REQUIRED — `data` * a record, `error` a nullable string, `successful` a boolean — so the outcome of a call is a field * on a resolution and not only a thrown exception. Named here rather than imported so this module - * keeps no compile-time dependency on the vendor's package; the adapter that installs the real - * client is the one place their types belong. + * keeps no compile-time dependency on the vendor's package; the adapter that would install a real + * client is the one place their types belong, and it has not been written. * * `logId` and `sessionInfo` are the rest of the envelope, carried so the type stays a true statement * about what arrives. Nothing here reads them and nothing here shows them to a model. @@ -93,7 +99,8 @@ export type ComposioResult = { * What this module needs of Composio, and nothing more. * * A narrow projection rather than their client, so a test satisfies it with two functions and the - * SDK's shape is somebody else's problem in exactly one place: the adapter that installs the real one. + * SDK's shape is confined to one place: the adapter that would install a real client. No such + * adapter exists yet, so today the only implementations of this type are stubs. * * `execute` RESOLVES AN OUTCOME, AND RESOLVING IS NOT SUCCEEDING. This comment used to say the * opposite — "resolves or throws, with no error field to check" — and {@link callTool} was written to @@ -128,7 +135,12 @@ export type ComposioActions = { let installed: ComposioActions | null = null; /** - * Hand this module its client, once, from the place that reads configuration. + * The seam an adapter would hand this module its client through, once, at startup. + * + * WOULD, BECAUSE NO SUCH ADAPTER EXISTS. Nothing under `server/src` calls this function — the only + * callers are tests — so `installed` is null on every deployment of the shipped product. The + * comment here used to describe `index.ts` doing the installing, and the code below was written + * around a state that was treated as an edge case when it is in fact the only state. * * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that * installs a stub has to be able to take it back out. It is also the unconfigured state — a @@ -584,11 +596,16 @@ export async function callTool( * a call against some other revision of the action, whose arguments and behaviour are not the ones * that were listed, classified and granted. * - * In practice this means the app's tool list has not been refreshed since the version column - * existed, which is an operator's one-click fix rather than anything a person asking can do. + * THE REMEDY IS CONDITIONAL ON THE VENDOR, and this sentence used to state it as certain. + * "Refresh this app's tools and try again" is right for one of the two causes — a list recorded + * before the version column existed — and wrong for the other. Where Composio published no + * version for the action, {@link listTools} records none, `store.ts` writes `tool.version ?? + * null`, and the next refresh writes the same null back: the reader presses the button, is told + * nothing changed, and presses it again. So the sentence names the refresh and names the + * condition under which it helps, which is the part nobody in this deployment controls. */ return failure( - `${toolName} has no recorded version, so it cannot be called. Refresh this app's tools on its Plugins page and try again.`, + `${toolName} has no recorded version, so it cannot be called: Composio requires a specific one and rejects "latest", so there is nothing to fall back on. Refreshing this app's tools on its Plugins page recovers it only if Composio publishes a version for this action. Where Composio publishes none, no refresh will make it callable.`, ); } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index d6c65632f..215219c88 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -486,6 +486,15 @@ describe("calling one action", () => { expect(result.isError).toBe(true); expect(result.text).toMatch(/version/i); expect(calls).toEqual([]); + + // The refusal used to name a refresh as THE fix, unconditionally. It is not one where the + // vendor published no version: `listTools` sets the field only when Composio sent one, so a + // refresh writes the same nothing back and the reader presses the button again. The sentence + // has to make the remedy conditional on the vendor, which is the part nobody here controls. + expect(result.text).not.toContain( + "Refresh this app's tools on its Plugins page and try again.", + ); + expect(result.text).toMatch(/only if Composio publishes/i); }); test("an actor named in the arguments is ignored, whichever way it is spelled", async () => { From 23c2b24fe973eddec7e9290dcf0379c773fb53a3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:02:21 +0200 Subject: [PATCH 65/93] Say that the schema reaching a model is the SDK's re-spelling and not the vendor's own Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 25 +++++++++++++++++- server/tests/composio-transport.test.ts | 35 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index f69faa0df..3338f63a2 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -67,7 +67,30 @@ export const LISTING_LIMIT = 1000; export type ComposioAction = { slug: string; description?: string; - /** JSON Schema, as they spell it. Absent for the occasional action that publishes none. */ + /** + * The action's JSON Schema as `@composio/core` re-spells it, which is NOT as Composio published it. + * + * This used to say "as they spell it", and that claim travelled: whatever lands in this field is + * what {@link listTools} puts in front of a model as the vendor's own schema. The SDK parses the + * response through `ToolSchema`, and its `ParametersSchema` is a plain `z.object` with no + * passthrough (`@composio/core` 0.18.1, `src/types/tool.types.ts:134-174`), so every key it does + * not name is dropped before anything here can see it. At the schema ROOT that is `if`, `then`, + * `else`, `examples` and every `x-` extension. Per property, `JSONSchemaPropertySchema` (`:77-131`) + * does keep `if`/`then`/`else`/`examples`, but names neither `deprecated` nor `contentEncoding`, + * so both of those go. + * + * WHY THE CLAIM WAS DROPPED RATHER THAN THE LOSS FIXED. The strip happens inside the vendor's own + * parse, upstream of every byte this module receives, so there is nothing here to restore a key + * from — "stop losing them" is not an option this file has. The one place it could be avoided is + * the adapter that has yet to be written, by reading `client.tools.list` directly rather than + * `tools.getRawComposioTools` and never running `ToolSchema` over the answer; that is a decision + * about the vendor's types, and it belongs where the vendor's types belong. What this module can + * honestly promise is the narrower thing: it adds nothing to this schema and removes nothing from + * it, so what the SDK handed over is exactly what a model is shown. + * + * Absent for the occasional action that publishes none — and equally for one that published `{}`, + * which the SDK normalizes to absent before parsing (`src/models/Tools.ts:76-93`). + */ inputParameters?: Record; /** Behaviour labels mixed in with topical ones. See {@link effectOf}. */ tags?: string[]; diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 215219c88..c740ff621 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -344,6 +344,41 @@ describe("listing an app's actions", () => { expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); }); + test("the schema a model is shown is the one the SDK handed over, unaltered", async () => { + /* + * A CHARACTERIZATION TEST, and it passed before the claim beside `inputParameters` was + * corrected — the correction is to a comment, because the loss it describes happens inside + * `ToolSchema.parse` and there is no key left here to restore. + * + * What it pins is the narrower promise that replaced the false one: this module adds nothing to + * the schema and removes nothing from it. The keys below are ones `ParametersSchema` and + * `JSONSchemaPropertySchema` would have stripped, so a real client never delivers them — which + * is exactly why they are the right probe for whether anything HERE also strips. `listTools` + * now walks the schema looking for a file parameter, and a walk that rebuilt what it read + * would silently narrow every schema in the listing. + */ + const schema = { + type: "object", + properties: { + query: { type: "string", deprecated: true, contentEncoding: "utf-8" }, + }, + if: { required: ["query"] }, + // No `then` beside it: biome bans a `then` key on an object literal, and the point of these + // is only that they are root keywords `ParametersSchema` does not name. + else: { required: [] }, + examples: [{ query: "is:unread" }], + "x-openbot-probe": "kept", + }; + useComposioClient( + recording({ + listActions: async () => [{ ...GMAIL_READ, inputParameters: schema }], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + expect(tool?.inputSchema).toEqual(schema); + }); + test("an action with no schema is still listed, with an open one", async () => { useComposioClient( recording({ From 749eef92237b1ae65deb4defe1aa4a98d9a5d8d6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:03:50 +0200 Subject: [PATCH 66/93] Stop saying Composio lists an action's schema to anybody, when it lists it to a key Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 3338f63a2..0c75e77d9 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -176,7 +176,16 @@ export function useComposioClient(client: ComposioActions | null): void { installed = client; } -/** The tool list needs no credential: Composio publishes an action's schema to anybody. */ +/** + * The tool list needs no credential FROM THE CONNECTION, which is not the same as needing none. + * + * The sentence here used to be "Composio publishes an action's schema to anybody", and the vendor's + * own client says otherwise: the listing is an authenticated request carrying the deployment's + * Composio API key, which the client holds and this module never sees. What is genuinely not + * required is a PERSON. An action's schema is the same whoever asks, so nothing about whose account + * is connected has to be settled before listing — which is exactly what this flag is asked to + * decide by `refreshTools`, and the only thing it decides. See {@link ./transport}. + */ export const listNeedsCredential = false; /** From fa84783b6939c18196c38bd332d3468b6186acb7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:04:42 +0200 Subject: [PATCH 67/93] Find a file parameter that is only reachable under a condition, not just under a property Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 0c75e77d9..985bc9d66 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -269,8 +269,11 @@ function schemaNode(value: unknown): Record | null { * only the top level of `properties` would answer false for every ref-based schema, which is the * majority of the ones that carry a file. * - * The keys walked are the composition keywords `ParametersSchema` and `JSONSchemaPropertySchema` - * actually keep, and no others: a key those two strip cannot be present to be walked. + * The keys walked are every subschema-bearing keyword `ParametersSchema` and + * `JSONSchemaPropertySchema` keep — a key those two strip cannot be present to be walked, so the + * list is closed. It is wider than the vendor's predicate by `patternProperties`, `not` and the + * conditional trio, which that one skips: a file staged only under a condition is still a file + * this deployment cannot stage. */ function stagesAFile(schema: unknown): boolean { const node = schemaNode(schema); @@ -287,7 +290,16 @@ function stagesAFile(schema: unknown): boolean { if (children && Object.values(children).some(stagesAFile)) return true; } - for (const key of ["anyOf", "oneOf", "allOf", "items", "not"]) { + for (const key of [ + "anyOf", + "oneOf", + "allOf", + "items", + "not", + "if", + "then", + "else", + ]) { const branch = node[key]; if ( Array.isArray(branch) ? branch.some(stagesAFile) : stagesAFile(branch) From 4f921bcc2db2a4492b9e9ed010172f809c91f15f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:10:07 +0200 Subject: [PATCH 68/93] Hold the version refusal to the conditional remedy it now offers The transport's sentence changed when the remedy became conditional on the vendor publishing a version at all, and this assertion still matched the old unconditional one. The comment above it claimed a one-click fix, which is the thing the new sentence exists to stop promising. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index c5bb97f8d..d6460b6b6 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3896,10 +3896,18 @@ test("a version a model supplied cannot stand in for an action with none recorde // vendor, rather than failing on a boolean that names nothing. expect(calls).toEqual([]); - // The transport's refusal, which is the advertised answer for an action with no recorded version — - // and an operator's one-click fix, rather than a call against a revision a model named. + // The transport's refusal, which is the advertised answer for an action with no recorded version, + // rather than a call against a revision a model named. The sentence offers a refresh CONDITIONALLY + // — it recovers the action only where Composio publishes a version for it — because where the + // vendor publishes none, no number of refreshes will make the action callable, and promising a + // one-click fix that cannot work sends an operator round a loop. expect(result.isError).toBe(true); - expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); + expect(result.text).toMatch( + /Refreshing this app's tools on its Plugins page recovers it only if/, + ); + expect(result.text).toMatch( + /Where Composio publishes none, no refresh will make it callable/, + ); }); /* From dc6d35312bb86632b1cf38cbb096974b988167cf Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:19:59 +0200 Subject: [PATCH 69/93] Fail these transport tests when the behaviour they are named for breaks Six of them passed whether the code did what their name said or not. The url-names-no-app test asserted the refusal and nothing else, so with the default stub answering `[]` the guard could be moved to after the dial and every assertion still passed while the transport handed a hostname to Composio as an app slug. The three cap tests allowed anything under 25,000 against a `MAX_RESULT_CHARS` of 20,000, so a cap raised to 24,000 passed, and the one named "capped visibly" never looked for the `[truncated]` marker at all. The empty-answer test read the sentence and neither of the two fields beside it, and the envelope test listed three strings that must not appear, which is only ever as long as the fields the envelope had the day it was written. The bounds are now the constant itself, imported rather than restated, and the envelope test pins the whole string. Four branches nothing reached are reached: an action Composio published no version for, and the empty-thrown-message arm of both fallbacks, plus `vendorSentence`'s trim and its type guard, each of which would otherwise hand a model a blank refusal or `[object Object]`. Every test here was confirmed red against a mutation of the behaviour it names, and no source file changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-transport.test.ts | 176 ++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 10 deletions(-) diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index c740ff621..001394865 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -10,6 +10,7 @@ import { useComposioClient, vendorSentence, } from "../src/plugins/composio"; +import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; /** * The Composio transport's boundary, asserted with no network and no database. @@ -48,6 +49,24 @@ function answered( }; } +/** + * What a capped answer ends with, and exactly how long a capped answer is. + * + * IMPORTED RATHER THAN RESTATED, and asserted as an equality rather than as an upper bound. The + * three cap tests below used to allow anything under 25,000 while `MAX_RESULT_CHARS` is 20,000, so + * the number they were named for was the one thing they did not pin: a regression that doubled the + * cap to 40,000 fails, but one that raised it to 24,000 — half again as much of somebody's context + * window spent by somebody else's server — passed. A bound with slack in it is a bound that has to + * be re-argued every time the constant moves, so there is no reason for it to have any. + */ +const TRUNCATION_MARKER = "\n\n[truncated]"; +const CAPPED_LENGTH = MAX_RESULT_CHARS + TRUNCATION_MARKER.length; + +/** The nesting `vendorSentence` reaches through, with whatever the vendor left at the bottom of it. */ +function nested(message: unknown): unknown { + return { cause: { error: { error: { message } } } }; +} + function recording(answers: Partial = {}): { client: ComposioActions; calls: Recorded[]; @@ -194,6 +213,25 @@ describe("finding the vendor's own sentence", () => { expect(vendorSentence({ cause: { error: {} } })).toBeNull(); expect(vendorSentence(undefined)).toBeNull(); }); + + test("a sentence made only of whitespace is not a sentence", () => { + // The `.trim()` on the return had nothing asserting it. A blank message that counted as a + // sentence is worse than none: `callTool` and `listingSentence` both prefer it over their + // fallbacks, so the reader gets an empty refusal instead of the one line naming what to do. + expect(vendorSentence(nested(""))).toBeNull(); + expect(vendorSentence(nested(" "))).toBeNull(); + expect(vendorSentence(nested("\n\t "))).toBeNull(); + }); + + test("a message that is not a string is not read as one", () => { + // Nothing asserted the type guard either. Composio's payloads are somebody else's JSON, so the + // field can be a number, an object or null; handed on unchecked, each of those reaches a model's + // context and an audit row as `[object Object]` or `1810`. + expect(vendorSentence(nested(1810))).toBeNull(); + expect(vendorSentence(nested(null))).toBeNull(); + expect(vendorSentence(nested({ text: "a nested sentence" }))).toBeNull(); + expect(vendorSentence(nested(["a sentence in a list"]))).toBeNull(); + }); }); describe("listing an app's actions", () => { @@ -397,6 +435,27 @@ describe("listing an app's actions", () => { expect(tool?.effect).toBe("write"); }); + test("an action Composio published no version for is listed with no version key", async () => { + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_UNVERSIONED", tags: ["readOnlyHint"] }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + // The branch that spreads the key only when the vendor sent one had nothing exercising it: + // every listing stub above carries a version. What it guards is not cosmetic. `store.ts` + // writes `tool.version ?? null`, so a key present and empty would be recorded as a version this + // deployment believes it has, and `callTool` would send `""` to a vendor that rejects it — + // instead of the refusal that names what the reader can and cannot do about it. + expect(Object.keys(tool ?? {})).not.toContain("version"); + expect(tool?.name).toBe("GMAIL_UNVERSIONED"); + expect(tool?.effect).toBe("read"); + }); + test("a listing nobody was asked for throws rather than answering empty", async () => { // `[]` means "the vendor was asked and advertises none" everywhere else in this codebase, and // `refreshTools` commits it as a healthy refresh. No client installed is the SHIPPED state — @@ -415,8 +474,26 @@ describe("listing an app's actions", () => { expect(thrown.message).toMatch(/expected/i); }); - test("a url that names no app throws about the url, not about the client", async () => { - useComposioClient(recording().client); + test("a url that names no app throws about the url, and asks nobody", async () => { + /* + * THE REFUSAL IS ONLY HALF THE CLAIM, and this test used to make only that half. + * + * With the default stub answering `[]`, nothing here noticed whether Composio had been asked + * at all — so the guard could be moved to after the dial and every assertion below still + * passed, while the transport handed `https://example.com` to the vendor as an app slug. That + * is the failure the guard exists to prevent: `toolkitOf` is what keeps a url this deployment + * cannot read from becoming a request, and a test that cannot tell a refusal from a round trip + * is not testing the guard. + */ + const asked: unknown[] = []; + useComposioClient( + recording({ + listActions: async (toolkit, page) => { + asked.push({ toolkit, page }); + return []; + }, + }).client, + ); const listing = listTools({ url: "https://example.com" }); @@ -427,6 +504,7 @@ describe("listing an app's actions", () => { const thrown = (await listing.catch((error: unknown) => error)) as Error; expect(thrown.message).not.toMatch(/not configured/i); expect(thrown.message).toContain("https://example.com"); + expect(asked).toEqual([]); }); test("a listing the vendor's own schema rejects throws a sentence, not a Zod dump", async () => { @@ -463,6 +541,29 @@ describe("listing an app's actions", () => { expect(String((thrown as Error).message)).not.toContain("invalid_type"); expect(String((thrown as Error).message)).toContain("gmail"); }); + + test("a listing that failed with nothing said still names the app it was about", async () => { + // The other arm of `listingSentence`'s fallback, which nothing reached. `refreshTools` puts + // this string in the row's `lastError` and an administrator reads it off the Plugins page, so + // a blank one is a refresh that reports having failed and declines to say about what. + for (const thrown of [{ status: 502 }, new Error(""), new Error(" ")]) { + useComposioClient( + recording({ + listActions: async () => { + throw thrown; + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message.trim()).not.toBe(""); + expect(message).toContain("gmail"); + } + }); }); describe("calling one action", () => { @@ -644,8 +745,16 @@ describe("calling one action", () => { { __version: "20260903_00" }, ); + // VISIBLY is the marker and RATHER THAN SILENTLY is the flag, and this test asserted only the + // flag. `truncated: true` beside text that just stops is exactly the silent cut the name + // promises against: the model reads a JSON document that ends mid-token and completes it from + // memory, because nothing in what it was handed says the ending is ours. + expect(result.isError).toBe(false); expect(result.truncated).toBe(true); - expect(result.text.length).toBeLessThan(25_000); + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); }); test("an empty answer says so in words rather than being empty", async () => { @@ -662,6 +771,10 @@ describe("calling one action", () => { // `data` is a required record, so the empty answer the SDK can actually produce is `{}` — if that // did not count, this branch would be unreachable and its promise would be a fiction. expect(result.text).toMatch(/returned nothing/i); + // Nothing to say is not a failure and is not a truncation. Both fields were unasserted, so this + // branch could have started reporting an error and the test would not have noticed. + expect(result.isError).toBe(false); + expect(result.truncated).toBe(false); }); test("an answer the vendor marked unsuccessful is a failure, not content", async () => { @@ -708,11 +821,14 @@ describe("calling one action", () => { ); expect(result.isError).toBe(false); - expect(result.text).toContain("m1"); - // `successful`, `error` and `logId` are the envelope this transport reads to decide the outcome. - // Reporting them as content spends a model's context on our own bookkeeping. - expect(result.text).not.toContain("log_must_not_appear"); - expect(result.text).not.toContain("successful"); + // `successful`, `error` and `logId` are the envelope this transport reads to decide the + // outcome. Reporting them as content spends a model's context on our own bookkeeping. Pinned + // as the whole string rather than as three absences, because a list of things that must not + // appear is only ever as long as the fields the envelope had on the day it was written — the + // vendor's `sessionInfo` is already in the type and named in none of them. + expect(result.text).toBe( + JSON.stringify({ messages: [{ id: "m1" }] }, null, 2), + ); }); test("an unsuccessful answer with no sentence still says something actionable", async () => { @@ -755,6 +871,39 @@ describe("calling one action", () => { expect(result.text).toMatch(/Plugins page/); }); + test("a failure that carries no message at all still says something actionable", async () => { + /* + * The empty-message arm of the fallback, which nothing reached. Both ways of arriving at it are + * real: `@composio/core` rejects with plain objects on some paths, so `error instanceof Error` + * is false and there is no message to read at all; and a thrown `Error` whose message is blank + * or whitespace is what a transport-level abort produces. + * + * Passed on unchanged, either one lands in a model's context and in `store.ts`'s audit row as an + * empty refusal — a failure with `isError: true` and nothing said, which reads to a model as + * permission to invent a reason and retry. + */ + for (const thrown of [{ status: 502 }, new Error(""), new Error(" \n ")]) { + useComposioClient( + recording({ + execute: async () => { + throw thrown; + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text.trim()).not.toBe(""); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + } + }); + test("an enormous vendor sentence is capped in a refusal too, and says so", async () => { useComposioClient( recording({ @@ -773,7 +922,11 @@ describe("calling one action", () => { // is the same unbounded spend the success path already refuses to make. expect(result.isError).toBe(true); expect(result.truncated).toBe(true); - expect(result.text.length).toBeLessThan(25_000); + // "and says so" is the marker, which nothing here used to check. + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); }); test("an enormous thrown message is capped in a refusal too", async () => { @@ -793,7 +946,10 @@ describe("calling one action", () => { expect(result.isError).toBe(true); expect(result.truncated).toBe(true); - expect(result.text.length).toBeLessThan(25_000); + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); }); test("our own serialization failure is not reported as the action having failed", async () => { From 056a0c5f038fa78ff63b1fae30f90d3e0d9fb0f7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:19:51 +0200 Subject: [PATCH 70/93] Refuse a brokered row whose id is a curated slug instead of dialling the curated vendor Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 48 +++++++++++++++++++++++++++- server/src/plugins/store.ts | 12 +++++-- server/tests/composio-access.test.ts | 46 ++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 15c339377..d0fa2f2a1 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -102,13 +102,53 @@ const REACHED_AS_BY_AUTH: Record< }; /** - * A reviewed entry decides for itself; otherwise the row decides. + * A row that claims to be two servers at once, which makes it neither. + * + * CRITERION. A row whose provenance says `composio` and whose id is a curated catalogue slug is + * refused, not resolved — in either direction. + * + * REASON. {@link accessFor} holds two facts and no third: the row, and the entry that row's id + * looked up. A curated row whose provenance column was edited to `composio` and a genuinely + * brokered app that happens to be named `notion` arrive here identically, so every answer is right + * about one of them and wrong about the other. Entry-wins picked the first reading and therefore + * dialled the second as MCP at the curated vendor's pinned host, spending the deployment's own + * grant instead of the asking person's brokered connection — the wrong vendor on the wrong + * credential, recorded in the trail as an ordinary call to a reviewed server. + * + * THE SAME COLLISION IS ALREADY REFUSED AT THE OTHER END. `addCustomServer` will not let a row take + * a curated slug, because the slug prefixes tool names and is what a grant and a policy rule are + * written against. There is no `addComposioServer` to copy that guard into — nothing in the shipped + * product writes a `composio` row at all — so a colliding row arrives only by hand edit or restore, + * and only a check at resolution sees one. + * + * NOBODY ASKED FOR THIS REFUSAL, so it is not a person's to act on mid-call: it is two of our own + * columns contradicting each other, the same shelf `PluginInvariantError` sits on. Declared here + * rather than imported from `store.ts` because this module is a leaf — `store.ts` imports it, and + * it imports nothing back. + */ +export class ServerRowAmbiguousError extends Error { + constructor(message: string) { + super(message); + this.name = "ServerRowAmbiguousError"; + } +} + +/** + * A reviewed entry decides for itself; otherwise the row decides — and a row that claims both is + * refused rather than resolved. * * THE ENTRY WINS, AND THAT ORDER IS THE SECURITY PROPERTY. A curated slug's behaviour comes from code * that was reviewed, so a row whose provenance column says something else — edited by hand, restored * from an old backup, written by a bug — cannot turn a reviewed vendor into a brokered one and start * sending its calls somewhere else. The row only ever answers where the catalogue is silent. * + * IT CUTS BOTH WAYS, WHICH IS WHY `composio` IS REFUSED RATHER THAN OVERRULED. Only one direction + * was considered when that order was written: a brokered row whose id collides with a curated slug + * was quietly answered as the curated vendor. Nothing in these two arguments tells that row apart + * from a tampered curated one, so the only answer that is not wrong in one of the two worlds is no + * answer. See {@link ServerRowAmbiguousError}. Every other provenance value still loses to the + * entry, because none of them proposes a different vendor to reach. + * * MCP stays the fallback, which is still right for a server an administrator added by URL: that is * somebody else's MCP endpoint by definition, reached on the one token the deployment holds for it. */ @@ -116,6 +156,12 @@ export function accessFor( row: { provenance: string; url: string }, entry: CatalogueEntry | null, ): ServerAccess { + if (entry && row.provenance === "composio") { + throw new ServerRowAmbiguousError( + `${entry.key} is a server this deployment ships an entry for, and a row with that id says its provenance is composio. Nothing can tell an edited column from a brokered app that took the name, so this row is not resolved at all: rename it, or correct its provenance.`, + ); + } + if (entry) { return { transport: entry.transport ?? "mcp", diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 61db3b274..74367ac58 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2153,14 +2153,20 @@ export function createPluginStore(options: PluginStoreOptions) { * some other app's connections, or none, for the very row shape that gate already refuses to * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. * + * ASKED WITH NO ENTRY, deliberately, and that is not the entry-wins order being dodged. An + * entry can only ever SUPPRESS this answer — `accessFor` returns a null toolkit for every row + * that has one — so passing the entry a colliding id looks up would hide the brokered state of + * the one row most in need of clearing, and would now refuse outright the very row this method + * exists to get rid of, leaving the collision unremovable. Nothing is dialled here, so there is + * no vendor for an entry to protect; the only question is which app's consent rows this row's + * own url stands for. + * * Before the server row goes, for the reason the revokes above are: what a failure between * two writes leaves has to be the recoverable half. A connection cleared with the app still * present is fixed by removing it again; an app deleted with the connections standing is * reachable by no operation at all, because the toolkit was only ever readable off its url. */ - const toolkit = existing - ? accessFor(existing, catalogueEntry(serverId)).toolkit - : null; + const toolkit = existing ? accessFor(existing, null).toolkit : null; if (toolkit) { const connected = await database diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index 2187a340c..06b59497c 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { accessFor } from "../src/plugins/access"; +import { accessFor, ServerRowAmbiguousError } from "../src/plugins/access"; import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry, resolveServerUrl } from "../src/plugins/catalogue"; @@ -143,8 +143,12 @@ describe("accessFor", () => { // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one, // and must not acquire an app at the broker either — a url edited to `composio://gmail` on a // curated slug is the same tampering by another field. + // + // `composio` is deliberately NOT the value used here. That one combination is now refused + // outright rather than overruled — see the test below for why the entry cannot arbitrate it — + // and this test is about every other value the column can hold, where the entry still decides. const shadowed = accessFor( - { provenance: "composio", url: "composio://gmail" }, + { provenance: "custom", url: "composio://gmail" }, notion, ); expect(shadowed.transport).toBe("mcp"); @@ -152,6 +156,44 @@ describe("accessFor", () => { expect(shadowed.toolkit).toBeNull(); }); + test("a brokered row that carries a curated slug is refused, not dialled at the curated vendor", () => { + const notion = catalogueEntry("notion"); + // Thrown for the reason the test above throws: a renamed slug must break this file rather than + // quietly stop checking the protection it exists for. + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here checks that a colliding row is refused", + ); + } + // Two different rows produce this pair of arguments and nothing in them tells the two apart: a + // curated Notion row whose provenance column was edited to `composio`, and a genuinely brokered + // Notion app whose id happens to be the catalogue's slug. Entry-wins answered as though only + // the first existed, so the second was dialled as MCP at Notion's pinned host on the + // deployment's grant rather than the person's brokered connection. Refusing is the only answer + // that is not wrong in one of the two worlds. + expect(() => + accessFor({ provenance: "composio", url: "composio://notion" }, notion), + ).toThrow(ServerRowAmbiguousError); + + // The url is not what makes it ambiguous. The ID is, and `entry` is how this function is told + // the id collided — so a brokered row pointed at some other app is refused on the same ground, + // and nothing here can be satisfied by reading the url more carefully. + expect(() => + accessFor({ provenance: "composio", url: "composio://gmail" }, notion), + ).toThrow(ServerRowAmbiguousError); + + // And the refusal is the collision's, not the provenance value's: the same row with no curated + // entry behind its id resolves exactly as any other brokered row does. + expect( + accessFor({ provenance: "composio", url: "composio://notion" }, null), + ).toEqual({ + transport: "composio", + credential: "brokered", + reachedAs: "person", + toolkit: "notion", + }); + }); + test("which app a Composio row is comes from its url, not from its id", () => { // The id is a display key and the url is what the transport dials, so the url is what decides. // A row named `gmail` at `composio://slack` used to be checked against a Gmail connection and From ce6fc8a3fe669a4b608c03923bd4becc19eed5f4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:20:45 +0200 Subject: [PATCH 71/93] Say that an empty listing came from a vendor that was asked, because now it did Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 48 +++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 74367ac58..2edabc0cd 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2369,26 +2369,34 @@ export function createPluginStore(options: PluginStoreOptions) { * nothing to lose, so the empty answer falls through to the replace below and commits — a * refresh stamp, no error, no actions. * - * REASON. `listTools` returning `[]` is supposed to mean "the vendor was asked and advertises - * none", and for three of the four transports it does. `composio.listTools` breaks it: it - * answers `[]` when no client is installed, and NOTHING IN THE SHIPPED PRODUCT CALLS - * `useComposioClient` — so on every real deployment that is the only answer a Composio refresh - * can produce. Committed, it deleted every `mcp_tools` row for the app, taking the recorded - * `effect`, `destructive` and `version` with it. `version` is the one that cannot be - * reconstructed: `callTool` refuses an action without it, so a refresh that reported success - * broke every subsequent call. The grants survived, pointing at rows that no longer existed — - * absent from `listServers`, and revived by a later refresh that worked. + * REASON. The replace below is a delete and an insert, so an empty answer committed here + * deletes every `mcp_tools` row for the server, taking the recorded `effect`, `destructive` + * and `version` with it. `version` is the one that cannot be reconstructed: `callTool` refuses + * an action without it, so a refresh that reported success broke every subsequent call, and + * the grants survived pointing at rows that no longer existed — absent from `listServers`, and + * revived only by a later refresh that worked. + * + * WHAT USED TO REACH THIS LINE, and no longer does. `composio.listTools` once answered `[]` + * for a url naming no app and for a deployment with no Composio client installed, neither of + * which is a vendor's answer, and the second of those is the state of every real deployment. + * Both throw now, so that particular arrival is closed at the seam rather than here. The guard + * stays because its argument never depended on who sent the empty answer. * * KEPT RATHER THAN TRUSTED, and that asymmetry is the whole argument. Holding actions the vendor * has withdrawn is visible and reversible: the next listing replaces them. Deleting actions the * vendor never withdrew is neither — `mcp_tools` is shared, so it is every replica at once, and - * only a refresh from a deployment that can actually reach the vendor puts it back. + * only a refresh from a deployment that can actually reach the vendor puts it back. That holds + * for any vendor that suddenly lists nothing, whatever made it do so, which is why removing + * this would reopen the same data loss for a different reason. * - * THE SEAM REQUIREMENT this leans on, which is not satisfied today: a transport that could not - * ask anybody must THROW rather than return `[]`. `composio.ts:141` returns `[]` for a missing - * client and for a url that names no app, and until it throws instead there is no field on the - * listing that tells the two apart. Nothing here asks which transport it is talking to, so the - * fix belongs in that one line and not in a branch on `access.transport`. + * THE SEAM REQUIREMENT this leans on, and it is satisfied: a transport that could not ask + * anybody must THROW rather than return `[]`. `composio.listTools` opens with two throws that + * say which of the two it is — no app in the url, no client installed — and no transport has + * an early `return []` left in it at all: `builtin-routines` answers a static list, and `mcp` + * and `google-drive-rest` hand back only what a request returned. So an empty listing reaching + * this line is a vendor's own answer, which is what the sentence below says. Nothing here asks + * which transport it is talking to, and nothing has to: the requirement is met at each seam + * rather than branched on here. */ if (listed.length === 0) { const held = await database @@ -2401,9 +2409,13 @@ export function createPluginStore(options: PluginStoreOptions) { .update(mcpServers) .set({ // Named as the state it is, because "listed nothing" and "would not answer" send an - // operator to different places. No `toolsRefreshedAt`: that column says when this - // deployment last learned what the app offers, and it did not learn it here. - lastError: `This app listed no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check that the connector is configured for this deployment, then refresh again.`, + // operator to different places, and reaching this line settles which one it was: a + // listing that could not be made throws and lands in the `catch` above instead. So + // this sentence must not send anybody to check their configuration — that is the + // other state's sentence, written by the transport that refused. No + // `toolsRefreshedAt`: that column says when this deployment last learned what the app + // offers, and it did not learn it here. + lastError: `This app was asked and answered with no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check whether it still publishes them, then refresh again.`, updatedAt: new Date(), }) .where(eq(mcpServers.id, serverId)); From 8a375f5eadc1723b01cd0c8920146cad2886123f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:20:07 +0200 Subject: [PATCH 72/93] Refuse to delete a person this suite never created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownership guard asked about `mcp_servers`, `agents` and `composio_connections` and never about `users`, while `freshDatabase` deleted `users.user_leaver` before every test whatever the guard had decided. A real account at that id goes through ten cascades — sign-in accounts, live sessions, roles, channel memberships, per-Bot preferences, written instructions, skills, routines, per-user connector credentials — and the fixture then re-inserts a look-alike, so nothing says it happened. The guard now covers the person too, and the deletes wait on the same positive evidence the teardown does rather than on the assumption that a thrown `beforeAll` stops everything below it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index d6460b6b6..eddd608cb 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -195,13 +195,25 @@ let ownsFixtureIds = false; * from any screen and invisible to `retireConnectionsFor`, which exists to stop exactly that state. * Removing a real Bot takes six tables: its channel memberships, its agent profile, everyone's * preferences for it, its routines and all of their run history, its component exclusions and its - * plugin grants. The fixtures then re-insert byte-identical look-alikes, so nothing on screen would - * say it happened. + * plugin grants. Removing a real PERSON takes ten: their sign-in accounts and live sessions, their + * roles, their channel memberships and intelligence mappings, their per-Bot preferences and written + * instructions, their skills, their routines, and every per-user connector credential they hold — + * and nulls the owner off their agent profiles and SSO provider besides. The fixtures then re-insert + * byte-identical look-alikes, so nothing on screen would say it happened. * - * So the deletes in {@link freshDatabase} are unconditional and this is what makes them safe. + * The list below is every table this file deletes from at an id it did not invent — an id spelled + * the way production spells it, so a row already sitting at it belongs to somebody else. Each of + * `mcp_servers`, `agents`, `composio_connections` and `users` is asked about here, and nothing else + * needs to be: `mcp_tools` and `plugin_grants` are the two remaining unconditional deletes and both + * are reached only by a key that references one of these four, so a row at either could not exist + * without the guard having already refused over its parent. Every other delete in this file names + * an id carrying {@link suite}. + * + * So the deletes in {@link freshDatabase} are authorised by {@link ownsFixtureIds} and this is what + * makes them safe. */ beforeAll(async () => { - const [configuredServers, existingBots, existingConnections] = + const [configuredServers, existingBots, existingConnections, existingPeople] = await Promise.all([ database .select({ id: mcpServers.id }) @@ -217,6 +229,17 @@ beforeAll(async () => { .where( inArray(composioConnections.userId, ["user_asker", "user_leaver"]), ), + /* + * The person, who was missing from this guard entirely. + * + * `user_leaver` is inserted at and deleted at by the Composio fixtures, and the delete used to + * run whatever the guard had decided — so a real row at that id was removed, with the ten + * cascades above behind it, on a run the guard had already refused. + */ + database + .select({ id: users.id }) + .from(users) + .where(eq(users.id, "user_leaver")), ]); const found = [ @@ -225,15 +248,16 @@ beforeAll(async () => { ...existingConnections.map( (row) => `the composio_connections row for '${row.userId}'`, ), + ...existingPeople.map((row) => `the person '${row.id}'`), ]; if (found.length > 0) { throw new Error( `This suite owns ${found.join(", ")} outright — it inserts at those exact ids and deletes ` + "them before every test — and refuses to run against a database that already has them, " + - "because deleting a real server row takes every person's per-user credentials with it and " + - "deleting a real Bot takes the six tables behind it. Point DATABASE_URL at a scratch " + - "database.", + "because deleting a real server row takes every person's per-user credentials with it, " + + "deleting a real Bot takes the six tables behind it, and deleting a real person takes the " + + "ten behind them. Point DATABASE_URL at a scratch database.", ); } @@ -3363,14 +3387,32 @@ describe("a vendor reply that is not a token", () => { * `bot_helper` and `user_asker` name them in every assertion. * * What replaces the suffix is the guard at the top of this file, not the ordering below. The deletes - * here are unconditional and would be indefensible on their own — a real `notion` row cascades into - * every person's per-user credentials, a real Bot into the six tables behind it. They are safe only - * because nothing gets this far unless the guard has already established that no row at any of these - * ids exists, which makes every row they remove one of this file's own. Cleaning before each test - * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; - * the `afterAll` below is what stops the last test's fixtures from outliving the run. + * here would be indefensible on their own — a real `notion` row cascades into every person's per-user + * credentials, a real Bot into the six tables behind it, a real person into the ten behind them. They + * are safe only because the guard has established that no row at any of these ids exists, which makes + * every row they remove one of this file's own, and the first thing below is the check that it did. + * Cleaning before each test rather than after is then just so a run that dies halfway leaves the next + * one nothing to trip over; the `afterAll` below is what stops the last test's fixtures from outliving + * the run. */ async function freshDatabase(): Promise { + /* + * The guard's answer, asked again rather than assumed. + * + * "Nothing gets this far unless the guard has established the ids are free" was the whole + * justification for the deletes below, and it was an assumption about the runner: a `beforeAll` + * that throws is supposed to stop every test under it. The rest of this file already declines to + * rely on that — `afterAll` is documented as running anyway, which is why {@link ownsFixtureIds} + * exists at all — and a delete cascading through a real person is not a thing to leave resting on + * the difference. So the same positive evidence the teardown waits for authorises these too, and + * a run that never got it fails loudly here instead of quietly emptying rows. + */ + if (!ownsFixtureIds) { + throw new Error( + "the ownership guard has not cleared this run to own 'gmail', 'notion', 'bot_helper', " + + "'user_asker' and 'user_leaver', so nothing may be deleted at those ids", + ); + } /* * The Bot's own grants, never a delete by ref. * @@ -3397,6 +3439,9 @@ async function freshDatabase(): Promise { await database .delete(composioConnections) .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + // The person the connection outlives, who is a row in `users` like anybody else. Reached only + // through the check at the top of this function, because there is no suffix on this id to tell a + // fixture apart from somebody's account and ten cascades sit behind the difference. await database.delete(users).where(eq(users.id, "user_leaver")); return database; } From 1aad7c8a678fbb528b8d1d50fdb4484f53378767 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:23:23 +0200 Subject: [PATCH 73/93] Take back every credential row this file mints, and put back only a client it actually saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways a row outlived the run. The dynamic-registration suite wrapped the vault's `create` and not its `rotate`, so every reconnect and every re-registration after the first minted a `notion` credential nothing recorded — thirteen rows a run, the last of them a live `mcp_user_token` for a person, revoked by nothing and referenced by nothing. The custom-credential suite minted four tokens and deleted three, leaving the upsert's own live in the vault against a server that no longer exists. And both OAuth suites restored `mcp_servers.credential_id` from a variable only their `beforeAll` assigns, while `afterAll` runs whether or not that `beforeAll` finished — so a setup that died early wrote null over a deployment's client and then deleted the row, on the strength of a flag still sitting at its initialiser. The capture now starts at `undefined`, which is nobody having looked, and both the restore and the delete wait on evidence rather than on an absence of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 94 ++++++++++++++----- 1 file changed, 69 insertions(+), 25 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index eddd608cb..57e44493a 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -1239,14 +1239,24 @@ describe("refresh token rotation", () => { sent.length = 0; } - let notionWasAlreadyConfigured = false; + /** + * Whether THIS RUN is what put the `notion` row there, and so is what should take it away. + * + * Counting creations rather than absences, the same way round as {@link suiteCreatedServerRow} and + * for the same reason: `afterAll` runs even when the `beforeAll` below it has thrown, and a flag + * still sitting at its initialiser then authorised the delete. Only a capture that ran and found + * the row missing can write the value the delete needs. + */ + let suiteCreatedNotionRow = false; /** * The OAuth client this deployment had before the suite ran, restored afterwards. * - * `mcp_servers.credential_id` is live configuration, and this suite repoints it. Restored - * unconditionally, because the delete below removes the row it would otherwise still address. + * `mcp_servers.credential_id` is live configuration, and this suite repoints it. `undefined` is + * "nobody looked", which is what the value is until the capture below runs and is what it is still + * sitting at if that `beforeAll` threw first — and a restore that treated it as `null` would not be + * restoring anything, it would be blanking a real deployment's client on the way out. */ - let clientBefore: string | null = null; + let clientBefore: string | null | undefined; beforeAll(async () => { await database @@ -1272,7 +1282,7 @@ describe("refresh token rotation", () => { .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) .from(mcpServers) .where(eq(mcpServers.id, rotationServerId)); - notionWasAlreadyConfigured = existing !== undefined; + suiteCreatedNotionRow = existing === undefined; clientBefore = existing?.credentialId ?? null; // Written directly, so the test needs no vendor to be reachable. What is under test is which @@ -1314,11 +1324,15 @@ describe("refresh token rotation", () => { eq(mcpUserCredentials.userId, rotationUserId), ), ); - // Before the deletes, because the column addresses one of the rows they remove. - await database - .update(mcpServers) - .set({ credentialId: clientBefore }) - .where(eq(mcpServers.id, rotationServerId)); + // Before the deletes, because the column addresses one of the rows they remove. Only when the + // capture actually ran: `undefined` is nobody having looked, and writing that back as null is + // not a restore. + if (clientBefore !== undefined) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, rotationServerId)); + } for (const id of vaultRows) { await database.delete(credentials).where(eq(credentials.id, id)); } @@ -1339,7 +1353,7 @@ describe("refresh token rotation", () => { ), ); // A server row is deployment configuration, so it goes only if this suite is what added it. - if (!notionWasAlreadyConfigured) { + if (suiteCreatedNotionRow) { await database .delete(mcpTools) .where(eq(mcpTools.serverId, rotationServerId)); @@ -1868,7 +1882,15 @@ describe("a dynamic client the vendor has evicted", () => { * * Genuine rather than stubbed, because what this suite asserts is that a re-registered client is * KEPT — which is a write and a read back through the encryption, not a call that was made. The - * one wrapper is the bookkeeping that lets the cleanup take exactly this suite's rows. + * wrappers are the bookkeeping that lets the cleanup take exactly this suite's rows. + * + * BOTH ways a row is minted, not just the first. `create` is the vault's answer when the key holds + * no live row; `rotate` is its answer when one does, and `recordConnection` and `storeOAuthClient` + * each pick between them on exactly that. So every reconnect after the first and every + * re-registration after the first went through `rotate` — which the spread handed straight to the + * real vault, unrecorded. This suite reconnects and re-registers repeatedly, and each run left + * thirteen `notion` credential rows nothing would ever remove, the last of them LIVE: an + * `mcp_user_token` for a person, unrevoked and referenced by nothing. */ const realVault = createCredentialStore(database); const vault = { @@ -1890,6 +1912,15 @@ describe("a dynamic client the vendor has evicted", () => { vaultRows.push(row.id); return row; }, + /** The same forwarding, for the same reason: `rotate` runs inside the caller's transaction too. */ + rotate: async ( + value: Parameters[0], + executor?: Parameters[1], + ) => { + const row = await realVault.rotate(value, executor); + vaultRows.push(row.id); + return row; + }, }; /** @@ -2098,9 +2129,15 @@ describe("a dynamic client the vendor has evicted", () => { actorId: dynamicUserId, }); - let notionWasAlreadyConfigured = false; - /** This deployment's own client, restored afterwards: the column is live configuration. */ - let clientBefore: string | null = null; + /** Whether THIS RUN put the `notion` row there. Counted, never inferred from an absence. */ + let suiteCreatedNotionRow = false; + /** + * This deployment's own client, restored afterwards: the column is live configuration. + * + * `undefined` until the capture runs, so a `beforeAll` that dies before it leaves a teardown that + * knows it has nothing to put back rather than one that writes null over somebody's client. + */ + let clientBefore: string | null | undefined; // The vendor refuses the ordinary way unless a test says otherwise, so a test that varies the // refusal cannot leave the next one asserting against somebody else's setup. @@ -2132,7 +2169,7 @@ describe("a dynamic client the vendor has evicted", () => { .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) .from(mcpServers) .where(eq(mcpServers.id, dynamicServerId)); - notionWasAlreadyConfigured = existing !== undefined; + suiteCreatedNotionRow = existing === undefined; clientBefore = existing?.credentialId ?? null; await database @@ -2170,11 +2207,14 @@ describe("a dynamic client the vendor has evicted", () => { eq(mcpUserCredentials.userId, dynamicUserId), ), ); - // Before the deletes, because the column addresses one of the rows they remove. - await database - .update(mcpServers) - .set({ credentialId: clientBefore }) - .where(eq(mcpServers.id, dynamicServerId)); + // Before the deletes, because the column addresses one of the rows they remove. Skipped + // entirely when no capture ran, for the reason on {@link clientBefore}. + if (clientBefore !== undefined) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, dynamicServerId)); + } for (const id of vaultRows) { await database.delete(credentials).where(eq(credentials.id, id)); } @@ -2194,7 +2234,7 @@ describe("a dynamic client the vendor has evicted", () => { eq(mcpTools.name, dynamicToolName), ), ); - if (!notionWasAlreadyConfigured) { + if (suiteCreatedNotionRow) { await database .delete(mcpTools) .where(eq(mcpTools.serverId, dynamicServerId)); @@ -3074,12 +3114,16 @@ describe("a custom server may only be pointed at its own kind of credential", () await database .delete(mcpServers) .where(like(mcpServers.id, `${customServerId}%`)); + // Every id the `beforeAll` above minted, which is the list this one has to match. The upsert's + // own token was missing from it, so each run left one live `mcp` credential behind for a server + // that no longer exists — a secret in the vault reachable from nothing. await database .delete(credentialRows) .where( inArray(credentialRows.id, [ deploymentCredentialId, personalCredentialId, + upsertCredentialId, oauthClientCredentialId, ]), ); @@ -3574,9 +3618,9 @@ afterEach(() => useComposioClient(null)); * {@link freshDatabase} cleans BEFORE each test, so without this the final test's rows are * permanent: a `notion` server row and a `notion-fetch` action nobody configured, which makes * whatever database this ran against advertise a connector nobody set up. Worse on the next run — - * the rotation and dynamic-registration suites above capture `notionWasAlreadyConfigured` from the - * leak, correctly decline to clean what looks like the deployment's own row, and leave the - * unconditional delete as the only thing that removes it. + * the rotation and dynamic-registration suites above read the leak as a row they did not create, + * correctly decline to clean what looks like the deployment's own, and leave this delete as the only + * thing that removes it. * * Exactly what this file created, and only when the guard cleared the run to own these ids. */ From e294a1f2ea1192c0fce341d31a36b11f37d75e11 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:28:22 +0200 Subject: [PATCH 74/93] Narrow every audit assertion to the run that is being asserted about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `audit_events` is append-only and 0012 closed the last way around that, so this file cannot tidy the trail and every row every previous run wrote is still there. The refusals are recorded against `google-drive/search_files` and named by rules about `google-drive`, spellings forced for the same reason the fixtures are, so the queries matched nine hundred rows this run had nothing to do with. The reader test's `limit(1)` was in fact answered by the oldest row in the table — a refusal from yesterday, written by code that is not this branch's — and the dry-run test read `recorded[0]` out of the same pile. Those are tests that cannot fail: delete the line that writes the row and they stay green. Every query now carries the run's start, taken from the database's own clock in the first hook the file registers, and the two that indexed into an unordered list say how many rows they expect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 57e44493a..64d2c8c8a 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -9,7 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; -import { and, asc, eq, inArray, like, sql } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -149,6 +149,34 @@ const store = createPluginStore({ policy: () => policy, }); +/** + * When this run began, by the DATABASE's clock, so every audit query can exclude what came before. + * + * The trail is the one table this file cannot tidy up after itself: `audit_events` is append-only, + * and 0012 closed the last way around that, so every row every previous run wrote is still there and + * still matches. The refusals are recorded against `google-drive/search_files` and named by rules + * about `google-drive` — production spellings, forced for the same reason the fixtures are — so a + * query narrowed only by target and rule matches nine hundred rows this run had nothing to do with, + * and an assertion that one exists is answered by a run that finished yesterday. That is a test + * which cannot fail: deleting the code that writes the row would leave it green. + * + * Postgres's clock rather than this process's, because the two are not the same clock and the + * comparison happens against a column the server stamps. + * + * Read through {@link sinceThisRun}, which refuses rather than defaulting: a bound of "the beginning + * of time" is the unscoped query back again, silently. + */ +let runStartedAt: Date | null = null; + +function sinceThisRun() { + if (!runStartedAt) { + throw new Error( + "the run's start was never recorded, so no audit query can be narrowed to it", + ); + } + return gte(auditEvents.createdAt, runStartedAt); +} + async function auditRowsFor(targetId: string) { return database .select({ @@ -162,6 +190,7 @@ async function auditRowsFor(targetId: string) { and( eq(auditEvents.targetType, "mcp_tool"), eq(auditEvents.targetId, targetId), + sinceThisRun(), ), ); } @@ -213,6 +242,16 @@ let ownsFixtureIds = false; * makes them safe. */ beforeAll(async () => { + /* + * Stamped here, in the first hook the file registers, so no row this run writes is older than it + * and no row an earlier run wrote is newer. + */ + const [clock] = await database.execute<{ now: Date }>( + sql`select now() as now`, + ); + if (!clock) throw new Error("the database would not say what time it is"); + runStartedAt = clock.now; + const [configuredServers, existingBots, existingConnections, existingPeople] = await Promise.all([ database @@ -618,7 +657,10 @@ describe("the policy is asked as well as the grant", () => { (row.payload as { decision?: { rule?: string } }).decision?.rule === rule, ); - expect(recorded.length).toBeGreaterThan(0); + // The one this call wrote. Exact, because the reads below are of `recorded[0]` and the list is + // in no order: with the rows of every previous run in it, that index was whichever the planner + // returned first, which is a row this code did not write. + expect(recorded).toHaveLength(1); /* * What tells this row apart from a call this deployment actually stopped. `allowed` is the * policy's answer and `carriedOut` is what the mode did with it, so a reader counting what a @@ -878,6 +920,14 @@ describe("removing an MCP server", () => { describe("the trail can be read by a second reader", () => { test("a refusal names the bot, the server and the tool in queryable JSON", async () => { + /* + * This run's refusal, not whichever of nine hundred the planner happened to hand back first. + * + * Unbounded, `limit(1)` was answered by the oldest row in the table — a refusal from a run + * whose Bot id no longer names anything — so the payload shape being asserted was a shape this + * branch's code had never written. Ordered as well as bounded, because `limit` without an order + * is a row the query plan picks. + */ const [row] = await database .select({ bot: sql`payload ->> 'bot'`, @@ -890,8 +940,10 @@ describe("the trail can be read by a second reader", () => { eq(auditEvents.targetType, "mcp_tool"), eq(auditEvents.eventType, "mcp.call_rejected"), eq(auditEvents.targetId, ref), + sinceThisRun(), ), ) + .orderBy(asc(auditEvents.createdAt), asc(auditEvents.id)) .limit(1); // Asserted in SQL rather than through the application, because the stored payload shape is the @@ -2096,6 +2148,8 @@ describe("a dynamic client the vendor has evicted", () => { and( eq(auditEvents.eventType, "mcp.oauth_client_registered"), eq(auditEvents.targetId, dynamicServerId), + // `notion` and `dyn-1` are fixed spellings, so without this the count is every run's. + sinceThisRun(), ), ); } @@ -2702,6 +2756,14 @@ describe("a dynamic client the vendor has evicted", () => { eq(auditEvents.eventType, "configuration.changed"), eq(auditEvents.targetId, dynamicServerId), sql`payload ->> 'change' = 'unlisted_tools_advertised'`, + /* + * `named` is what THIS refresh recorded, not the union over every refresh there has + * ever been. `notion` and `notion-create-pages` are both fixed spellings, so both + * assertions below were being answered partly by rows older code wrote: the negative + * one would report today's classification as wrong on the strength of a row from + * before the write list covered that name. + */ + sinceThisRun(), ), ) ).flatMap((row) => (row.payload as { tools?: string[] }).tools ?? []); From ea0500e355e51fe8e2da1bcc9a4618510bf965d4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:31:06 +0200 Subject: [PATCH 75/93] Bring the anonymous actor inside the guard that covers every other id here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composio_connections.user_id` is notNull and notNull does not exclude the empty string, so `("gmail", "")` is a row a deployment can legally hold — which is exactly what the test that inserts one is about. The delete that takes it back runs inline, outside `freshDatabase` and outside every other sweep, at an id nothing had established was this file's. With it in the list, the guard's claim is now true of every table this file deletes from at an id it did not invent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 64d2c8c8a..b8183aafa 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -234,9 +234,11 @@ let ownsFixtureIds = false; * the way production spells it, so a row already sitting at it belongs to somebody else. Each of * `mcp_servers`, `agents`, `composio_connections` and `users` is asked about here, and nothing else * needs to be: `mcp_tools` and `plugin_grants` are the two remaining unconditional deletes and both - * are reached only by a key that references one of these four, so a row at either could not exist - * without the guard having already refused over its parent. Every other delete in this file names - * an id carrying {@link suite}. + * are reached only by a key that references one of these four — `mcp_tools.server_id` names a + * server, `plugin_grants.agent_id` names a Bot — so a row at either could not exist without the + * guard having already refused over its parent. Every other delete in this file names an id + * carrying {@link suite}, and the reads that touch the deployment's own `google-drive` row skip + * their delete instead, on the {@link suiteCreatedServerRow} flags above. * * So the deletes in {@link freshDatabase} are authorised by {@link ownsFixtureIds} and this is what * makes them safe. @@ -262,11 +264,21 @@ beforeAll(async () => { .select({ id: agents.id }) .from(agents) .where(eq(agents.id, "bot_helper")), + /* + * The anonymous actor is one of these ids too. `composio_connections.user_id` is notNull and + * notNull does not exclude the empty string, so `("gmail", "")` is a row a deployment can + * legally hold — which is the whole point of the test that inserts one — and the delete that + * takes it back again runs inline, outside `freshDatabase` and outside every other sweep. + */ database .select({ userId: composioConnections.userId }) .from(composioConnections) .where( - inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + inArray(composioConnections.userId, [ + "user_asker", + "user_leaver", + "", + ]), ), /* * The person, who was missing from this guard entirely. From 3a4b46c98522aa847b0ca4ef440ea4cce45cf1a1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:17:43 +0200 Subject: [PATCH 76/93] Believe the vendor when it declares an action destructive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listTools` mapped a `tools/list` answer into name, description and schema and dropped `annotations` entirely. The MCP specification defines `annotations.destructiveHint` and servers publish it, so `effect` and `destructive` were never set for any MCP-transport listing. That silence was doing more work than it looked. The docblock on `ListedTool` asserted that an MCP server publishes no effect, and the argument that a recorded effect could not disturb any existing curated read rested on it — true of the behaviour, but only because the annotations were being thrown away. The consequence: a tool a vendor declares destructive fell through `classifyTool` to read for any curated entry whose hand-written `writeTools` happened to omit the name. Fail-open, on a permission-adjacent decision. Only `destructiveHint` is believed, and only on an explicit true. It can move an action from read to write and never the other way, so a server that lies with it restricts itself and nothing else. `readOnlyHint` is withheld: for a curated vendor it changes no answer, since an advertised name absent from `writeTools` already classifies as a read, and the one case where it would change an answer is a server an administrator added by URL, where believing it means letting an arbitrary server declare its own tools harmless. The SDK says as much where it declares the hints. The specification's default of destructive-when-not-read-only is likewise not applied, because applying it would reclassify every unannotated action of every MCP vendor as a write. A tool carrying no annotations is unchanged in every respect, so Notion's reviewed write list goes on deciding exactly as it did, and Drive never reaches this code at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/mcp.ts | 53 ++++++++- server/tests/mcp-listing.test.ts | 183 +++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 server/tests/mcp-listing.test.ts diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 021459c66..f53fb16a7 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; /** * The only place in this deployment that speaks MCP to somebody else's server. @@ -89,9 +90,17 @@ export type McpTool = { * A tool as a transport listed it, including anything that transport happens to know about it. * * Three optional fields rather than a separate type per transport, so `refreshTools` reads - * `tool.effect` with no cast and no `"effect" in tool` sniffing. Optional because most transports - * know none of it: an MCP server publishes no effect and no version, and a field it always left - * undefined would be an invitation to read it as meaning something. + * `tool.effect` with no cast and no `"effect" in tool` sniffing. Optional because a transport may + * know none of it for a given tool, and a field always left undefined would be an invitation to + * read it as meaning something. + * + * AN MCP SERVER CAN PUBLISH AN EFFECT, and this docblock used to say it could not. That sentence + * was not a stale comment, it was load bearing: the argument that surfacing a recorded effect could + * not disturb any existing curated read rested on MCP listings never carrying one, which was true + * only because {@link listTools} was discarding `annotations`. The specification defines + * `annotations.destructiveHint`, servers publish it, and a tool a vendor declared destructive was + * classifying as a read for any curated entry whose hand-written `writeTools` happened to omit the + * name. Version is the field MCP genuinely has no concept of; effect and destructive are not. * * `McpTool` stays exactly what a `tools/list` answer contains, because that is what it is for. */ @@ -274,8 +283,43 @@ async function withClient( */ export const listNeedsCredential = true; +/** + * ONLY THE HINT THAT NARROWS IS BELIEVED, and the omission of the other one is the decision here. + * + * The SDK declares four hints on `annotations` — `readOnlyHint`, `destructiveHint`, + * `idempotentHint`, `openWorldHint` — and warns in the same place that a client should never make + * tool use decisions from annotations a server it does not trust supplied. That warning is the + * whole design of this function. `destructiveHint` can only ever move an action from read to write, + * so a server that lies with it can restrict itself and nothing else. `readOnlyHint` moves an + * action the other way, and `classifyTool` exists to make sure nothing but review can do that. + * + * WHAT HONOURING `readOnlyHint` WOULD ACTUALLY BUY, which is the reason withholding it costs + * nothing. For a curated vendor it changes no answer: an advertised name absent from the reviewed + * `writeTools` already classifies as a read, so recording `read` for it lands on the same result by + * a worse route. For a name the reviewed list DOES hold, `classifyTool` consults review first and + * ignores the column, so the hint would be discarded anyway. The single case where it would change + * an answer is a server an administrator added by URL, which has no reviewed list behind it and + * whose every tool is a write for exactly that reason — and there, believing it means letting an + * arbitrary server declare its own tools harmless and be believed. Zero accuracy gained, one + * fail-open introduced, so it is not read at all. + * + * `destructiveHint` is taken on presence of `true` only, never inverted. The specification gives it + * a default of true when a tool is not read-only, and applying that default would reclassify every + * unannotated action of every MCP vendor as a write — correct by the letter and a mass revocation + * of grants people already hold. An absent hint stays absent, which leaves the reviewed list + * deciding exactly as it did before, and only an explicit declaration narrows anything. + * + * A server that sets both hints is contradicting itself, and is read as destructive. The + * specification says `destructiveHint` is meaningless while `readOnlyHint` is true, but resolving + * an incoherent listing towards the permissive reading is the one direction that could hurt. + */ +function declaredEffect(annotations: ToolAnnotations | undefined) { + if (annotations?.destructiveHint !== true) return {}; + return { effect: "write", destructive: true } as const; +} + /** What this server says it offers, right now. */ -export async function listTools(connection: Connection): Promise { +export async function listTools(connection: Connection): Promise { return withClient(connection, async (client) => { const result = await client.listTools(undefined, { timeout: LIST_TIMEOUT_MS, @@ -284,6 +328,7 @@ export async function listTools(connection: Connection): Promise { name: tool.name, description: tool.description ?? "", inputSchema: (tool.inputSchema ?? {}) as Record, + ...declaredEffect(tool.annotations), })); }); } diff --git a/server/tests/mcp-listing.test.ts b/server/tests/mcp-listing.test.ts new file mode 100644 index 000000000..7afabceba --- /dev/null +++ b/server/tests/mcp-listing.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; +import { catalogueEntry, classifyTool } from "../src/plugins/catalogue"; +import { listTools } from "../src/plugins/mcp"; + +/** + * What an MCP listing carries out of the transport, and what the classifier then makes of it. + * + * WHY THIS SUITE EXISTS. The MCP specification lets a server publish `annotations.destructiveHint`, + * and `listTools` used to drop the whole `annotations` object on the floor. Nothing failed: every + * MCP-listed tool simply arrived with no effect, `refreshTools` recorded null, and `classifyTool` + * fell through to the reviewed write list. That looked correct — and it was, for every name a + * person had already reviewed. For a name the reviewed list did not happen to hold, a tool the + * vendor had explicitly declared destructive classified as a READ. Fail-open on a + * permission-adjacent decision, and invisible, because the only evidence was a field never read. + * + * These cases pin the transport and the classifier TOGETHER rather than separately. A unit test of + * `listTools` alone would prove a field is copied; a unit test of `classifyTool` alone would prove + * a string is honoured. Neither would have caught this, because the defect lived exactly in the + * join: the transport never produced the string the classifier was already willing to act on. So + * each case here goes over a real MCP connection and then through the same call `store.ts` makes, + * `classifyTool(entry, name, true, tool.effect ?? null)` — what `refreshTools` writes to the column + * and what the call path reads back out of it. + */ + +const mock = new MCPMock(); +let url = ""; + +/** + * A tool definition including the annotations the mock's own type does not declare. + * + * `MCPToolDefinition` names only `name`, `description` and `inputSchema`, but the mock stores the + * definition it is handed and serves it back verbatim, so annotations really do cross the wire. + * Widening the type here rather than casting keeps the fixture honest: `ToolAnnotations` is the + * SDK's own declaration, so a hint renamed upstream fails this file at compile time instead of + * silently ceasing to be served. + */ +type AnnotatedTool = MCPToolDefinition & { annotations?: ToolAnnotations }; + +/** Every tool needs one: the SDK rejects an entire listing that omits a single `inputSchema`. */ +const NO_ARGUMENTS = { type: "object", properties: {} } as const; + +/** + * Notion's real catalogue entry, not a fabricated one. + * + * The question this suite answers is what happens to the connector this deployment actually ships, + * so the reviewed write list under test has to be the shipped one. `notion-fetch` is on Notion's + * advertised listing and absent from `writeTools`; `notion-update-page` is on `writeTools`. Those + * two names are what make the narrowing and the no-widening cases meaningful. + */ +const notion = catalogueEntry("notion"); + +/** + * A destructive action absent from Notion's reviewed write list. + * + * Deliberately a name `writeTools` does not hold, because a name it DOES hold classifies as a write + * whatever the listing says — which would make this case pass without the transport carrying + * anything at all. + */ +const destructiveUnreviewed: AnnotatedTool = { + name: "notion-purge-workspace", + description: "Removes everything.", + inputSchema: NO_ARGUMENTS, + annotations: { destructiveHint: true }, +}; + +/** A read the vendor labels as one, which the reviewed list already classified as a read. */ +const readOnlyUnreviewed: AnnotatedTool = { + name: "notion-fetch", + description: "Reads a page.", + inputSchema: NO_ARGUMENTS, + annotations: { readOnlyHint: true }, +}; + +/** A reviewed write that the vendor contradicts by calling it read-only. */ +const readOnlyButReviewedAsWrite: AnnotatedTool = { + name: "notion-update-page", + description: "The vendor claims this only reads.", + inputSchema: NO_ARGUMENTS, + annotations: { readOnlyHint: true }, +}; + +/** A tool with no annotations at all, which is what most MCP servers publish. */ +const unannotated: AnnotatedTool = { + name: "notion-search", + description: "Says nothing about what it does.", + inputSchema: NO_ARGUMENTS, +}; + +beforeAll(async () => { + mock + .addTool(destructiveUnreviewed) + .addTool(readOnlyUnreviewed) + .addTool(readOnlyButReviewedAsWrite) + .addTool(unannotated); + url = await mock.start(); +}); + +afterAll(async () => { + await mock.stop?.(); +}); + +/** + * The classification a refresh would commit for one listed tool. + * + * Written as `tool.effect ?? null` because that is literally what `refreshTools` inserts into + * `mcp_tools.effect`, and `classifyTool` distinguishes null from the empty string. Reproducing the + * `??` here rather than passing `tool.effect` through is the difference between testing the shipped + * path and testing a plausible one. + */ +const classify = ( + tools: Awaited>, + name: string, +) => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`the mock did not list ${name}`); + return classifyTool(notion, name, true, tool.effect ?? null); +}; + +describe("what an MCP listing tells the classifier", () => { + test("a tool the vendor declares destructive is a write", async () => { + /* + * The case the whole change exists for. Nothing in the reviewed list names + * `notion-purge-workspace`, so before the annotations were surfaced this returned "read" — a + * Bot with a read grant could have called it. + */ + const tools = await listTools({ url }); + + expect(classify(tools, "notion-purge-workspace")).toBe("write"); + }); + + test("a destructive tool is also carried as destructive, not only as a write", async () => { + // `effect` gates the call; `destructive` is what the confirmation card reads. A tool that + // arrived as a write with `destructive` false would be gated correctly and presented wrongly. + const tools = await listTools({ url }); + const purge = tools.find((tool) => tool.name === "notion-purge-workspace"); + + expect(purge?.destructive).toBe(true); + }); + + test("a vendor's read-only claim cannot take a reviewed write off the write list", async () => { + /* + * The direction the criterion forbids. `notion-update-page` is on the reviewed `writeTools`, + * and a server that says otherwise — whether mistakenly or because somebody stood up a server + * that says whatever it likes — must not be able to widen what a Bot may do. + */ + const tools = await listTools({ url }); + + expect(classify(tools, "notion-update-page")).toBe("write"); + }); + + test("a vendor's read-only claim records nothing at all", async () => { + /* + * WITHHELD ON PURPOSE, and pinned so the omission reads as a decision rather than as the same + * oversight being fixed. `readOnlyHint` can only ever move an action towards "read", which is + * the widening the classifier's ordering exists to prevent. For a catalogued vendor it would + * change nothing — an advertised name absent from `writeTools` is already a read — so it buys + * no accuracy; for a server an administrator added by URL there is no reviewed list at all, and + * honouring it would let that server declare its own tools harmless and be believed. The SDK + * says as much where it declares these hints: clients should never make tool use decisions + * based on annotations received from untrusted servers. Acting only on the hint that narrows is + * how that warning is honoured while a declared destructive tool still gets gated. + */ + const tools = await listTools({ url }); + const fetch = tools.find((tool) => tool.name === "notion-fetch"); + + expect(fetch?.effect).toBeUndefined(); + // And so the reviewed list still decides, exactly as it did before this change. + expect(classify(tools, "notion-fetch")).toBe("read"); + }); + + test("a tool with no annotations is unchanged in every respect", async () => { + // The overwhelmingly common case, and the one that says existing Notion grants survive: no + // annotations means nothing recorded, which means the reviewed list decides as it always did. + const tools = await listTools({ url }); + const search = tools.find((tool) => tool.name === "notion-search"); + + expect(search?.effect).toBeUndefined(); + expect(search?.destructive).toBeUndefined(); + expect(classify(tools, "notion-search")).toBe("read"); + }); +}); From d287c7ae5946b79235b674f77beb8fa041d03a1e Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:28:10 +0200 Subject: [PATCH 77/93] Walk the one keyword that was hiding a staged file from the listing filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `additionalProperties` is a full subschema in both `ParametersSchema` and `JSONSchemaPropertySchema` (`@composio/core` 0.18.1, `src/types/tool.types.ts:154` and `:111`), which is how a toolkit spells a bag of attachments. `stagesAFile` never descended it, so a `file_uploadable` parameter hidden there escaped the filter and the action was offered to a model under both auto-upload settings — a bucket key nobody here can issue, or a server-side path nobody should promise — and every call against it failed. The comment claiming the walked list was closed stated what BOUNDED it rather than what completed it, which is how the gap survived a reading. `patternProperties`, `not`, the conditional trio, `items` as a tuple and the comparison against `true` rather than truthiness were all walked already and asserted by nothing; each has a case now. Two assertions that could not fail go with it, because both were argued for in an earlier wave and neither held. The cap and page-size tests compared against the very constants under test: `MAX_RESULT_CHARS` 20,000 to 40,000 and `LISTING_LIMIT` 1000 to 20 were both applied to the modules and all 44 tests stayed green. And the "schema unaltered" test compared the answer to the same object reference the stub had handed over, so an in-place `delete` inside `listTools` passed it. The numbers are written out here now and read back from the modules in one test, and the schema is compared to a snapshot taken before the call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 20 ++- server/tests/composio-transport.test.ts | 185 ++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 16 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 985bc9d66..9446c0dff 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -269,11 +269,20 @@ function schemaNode(value: unknown): Record | null { * only the top level of `properties` would answer false for every ref-based schema, which is the * majority of the ones that carry a file. * - * The keys walked are every subschema-bearing keyword `ParametersSchema` and - * `JSONSchemaPropertySchema` keep — a key those two strip cannot be present to be walked, so the - * list is closed. It is wider than the vendor's predicate by `patternProperties`, `not` and the - * conditional trio, which that one skips: a file staged only under a condition is still a file - * this deployment cannot stage. + * WHAT BOUNDS THE LIST IS NOT WHAT COMPLETES IT, and this comment used to claim the second from the + * first. A keyword `ParametersSchema` and `JSONSchemaPropertySchema` strip cannot be present to be + * walked, so nothing outside those two needs a branch — but every subschema-bearing keyword inside + * them does, and `additionalProperties` had none. Both of them keep it as a FULL SUBSCHEMA + * (`src/types/tool.types.ts:154` and `:111`), which is how a toolkit spells a bag of attachments, so + * a file hidden there was offered to a model under both auto-upload settings and every call against + * the action failed. + * + * `additionalProperties` and `items` are unions rather than plain subschemas — the first with + * `boolean`, the second with a tuple array. The boolean arm falls out of {@link schemaNode} and the + * array arm is what the second loop's `Array.isArray` is for, so neither needs a case of its own. + * + * Wider than the vendor's predicate by `patternProperties`, `not` and the conditional trio, which + * that one skips: a file staged only under a condition is still a file this deployment cannot stage. */ function stagesAFile(schema: unknown): boolean { const node = schemaNode(schema); @@ -295,6 +304,7 @@ function stagesAFile(schema: unknown): boolean { "oneOf", "allOf", "items", + "additionalProperties", "not", "if", "then", diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 001394865..0e2a113ea 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -50,17 +50,24 @@ function answered( } /** - * What a capped answer ends with, and exactly how long a capped answer is. + * The two numbers this file reasons about, WRITTEN OUT rather than imported. * - * IMPORTED RATHER THAN RESTATED, and asserted as an equality rather than as an upper bound. The - * three cap tests below used to allow anything under 25,000 while `MAX_RESULT_CHARS` is 20,000, so - * the number they were named for was the one thing they did not pin: a regression that doubled the - * cap to 40,000 fails, but one that raised it to 24,000 — half again as much of somebody's context - * window spent by somebody else's server — passed. A bound with slack in it is a bound that has to - * be re-argued every time the constant moves, so there is no reason for it to have any. + * AN ASSERTION THAT IMPORTS THE CONSTANT IT IS ABOUT CANNOT FAIL WHEN THAT CONSTANT MOVES, because + * both sides move together. The previous version of this file replaced a loose bound with an + * equality against `MAX_RESULT_CHARS` itself and called the number pinned; it was not. Applied to + * the modules, `MAX_RESULT_CHARS` 20,000 → 40,000 and `LISTING_LIMIT` 1000 → 20 both left all 44 + * tests passing: the cap tests measured the answer against whatever the cap had just become, and + * the listing test asked for whatever page the module had just decided to ask for. + * + * So the literals live here, and one test below is the only place the imported constants are read. + * Changing either constant now reddens exactly that test, which is where the argument for the + * number belongs: 20,000 is how much of a model's context one tool result may spend, and 1000 is + * the vendor's stated page ceiling and therefore the whole listing. */ +const RESULT_CAP = 20_000; +const WHOLE_LISTING = 1000; const TRUNCATION_MARKER = "\n\n[truncated]"; -const CAPPED_LENGTH = MAX_RESULT_CHARS + TRUNCATION_MARKER.length; +const CAPPED_LENGTH = RESULT_CAP + TRUNCATION_MARKER.length; /** The nesting `vendorSentence` reaches through, with whatever the vendor left at the bottom of it. */ function nested(message: unknown): unknown { @@ -97,6 +104,45 @@ const GMAIL_READ = { version: "20260903_00", }; +/** A parameter that stages a file, in the shape `JSONSchemaPropertySchema` keeps it. */ +const FILE_PROPERTY = { type: "string", file_uploadable: true }; + +/** + * One subschema keyword carrying `sub`, hung off a property so the root stays a `ParametersSchema`. + * + * A COMPUTED KEY, for `then` and for nothing else: biome refuses a literal `then` key on an object + * literal, and the conditional trio has to be reachable here or the branch that walks it is being + * asserted by nothing. `if`, `then`, `else`, `items` and `$ref` live on `JSONSchemaPropertySchema` + * and not on the parameters root (`@composio/core` 0.18.1, `src/types/tool.types.ts:77-131` against + * `:134-175`), so a case for one of them has to nest to be a shape the vendor could send. + */ +function underProperty(keyword: string, sub: unknown): Record { + return { + type: "object", + properties: { field: { type: "object", [keyword]: sub } }, + }; +} + +/** One action whose whole schema is the case under test, listed beside a plain one. */ +function listing(inputParameters: Record) { + return recording({ + listActions: async () => [ + GMAIL_READ, + { slug: "GMAIL_STAGES_A_FILE", version: "20260903_00", inputParameters }, + ], + }).client; +} + +describe("the numbers these assertions are about", () => { + test("the modules hold the numbers this file has written out", () => { + // The only reads of the imported constants in this file. Every other assertion measures + // against the literals above, so a constant that moves reddens this one test — which states + // the number — instead of quietly redefining what all the others are checking. + expect(MAX_RESULT_CHARS).toBe(RESULT_CAP); + expect(LISTING_LIMIT).toBe(WHOLE_LISTING); + }); +}); + describe("which app a connection names", () => { test("the app slug comes off the url", () => { expect(toolkitOf("composio://gmail")).toBe("gmail"); @@ -258,7 +304,7 @@ describe("listing an app's actions", () => { // nothing in the short answer says a filter was applied. Asking for a page is therefore not an // optimisation, and the seam must not let a caller forget to. expect(asked).toEqual([ - { toolkit: "gmail", page: { limit: LISTING_LIMIT } }, + { toolkit: "gmail", page: { limit: WHOLE_LISTING } }, ]); }); @@ -266,7 +312,7 @@ describe("listing an app's actions", () => { useComposioClient( recording({ listActions: async () => - Array.from({ length: LISTING_LIMIT }, (_unused, index) => ({ + Array.from({ length: WHOLE_LISTING }, (_unused, index) => ({ ...GMAIL_READ, slug: `GMAIL_ACTION_${index}`, })), @@ -382,6 +428,110 @@ describe("listing an app's actions", () => { expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); }); + test("a file parameter is found down every subschema keyword the SDK keeps", async () => { + /* + * ONE CASE PER KEYWORD, because a keyword the walk does not descend is an action offered to a + * model under BOTH auto-upload settings — the parameter is either a bucket key nobody here can + * issue or a server-side path nobody should promise — so every call against it fails. + * + * `additionalProperties` is the one that was missing, and it is not exotic: both + * `ParametersSchema` and `JSONSchemaPropertySchema` keep it as a full subschema + * (`@composio/core` 0.18.1, `src/types/tool.types.ts:154` and `:111`), which is precisely how a + * toolkit spells "a bag of attachments". The rest were already walked and asserted by nothing. + */ + const hidden: { where: string; schema: Record }[] = [ + { + where: "additionalProperties at the root", + schema: { type: "object", additionalProperties: FILE_PROPERTY }, + }, + { + where: "additionalProperties under a property", + schema: underProperty("additionalProperties", FILE_PROPERTY), + }, + { + where: "patternProperties at the root", + schema: { + type: "object", + patternProperties: { "^attachment_": FILE_PROPERTY }, + }, + }, + { + where: "patternProperties under a property", + schema: underProperty("patternProperties", { any: FILE_PROPERTY }), + }, + { + where: "not at the root", + schema: { type: "object", not: FILE_PROPERTY }, + }, + { where: "not", schema: underProperty("not", FILE_PROPERTY) }, + { where: "if", schema: underProperty("if", FILE_PROPERTY) }, + { where: "then", schema: underProperty("then", FILE_PROPERTY) }, + { where: "else", schema: underProperty("else", FILE_PROPERTY) }, + { where: "items", schema: underProperty("items", FILE_PROPERTY) }, + { + where: "items as a tuple", + schema: underProperty("items", [{ type: "string" }, FILE_PROPERTY]), + }, + { where: "oneOf", schema: underProperty("oneOf", [FILE_PROPERTY]) }, + { where: "allOf", schema: underProperty("allOf", [FILE_PROPERTY]) }, + { + where: "definitions at the root", + schema: { + type: "object", + properties: { body: { $ref: "#/definitions/upload" } }, + definitions: { upload: FILE_PROPERTY }, + }, + }, + ]; + + for (const { where, schema } of hidden) { + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + // The keyword is carried into the comparison so a failure names which one escaped. + expect({ where, offered: listed.map((tool) => tool.name) }).toEqual({ + where, + offered: ["GMAIL_FETCH_EMAILS"], + }); + } + }); + + test("an action is dropped only where the flag is actually set", async () => { + /* + * THE OTHER HALF OF THE WALK, which decides what stays offered. `file_uploadable` is + * `z.boolean().optional()` (`src/types/tool.types.ts:89`), so `false` is a value the vendor + * really sends and the comparison against `true` rather than against truthiness is what keeps + * it from dropping an action nobody has to stage anything for. `additionalProperties` is a + * union with `boolean` (`:111`, `:154`), so `true` and `false` arrive there as values and the + * walk has to read them as "not a subschema" instead of tripping over them. + */ + const offered: { where: string; schema: Record }[] = [ + { + where: "the flag is explicitly false", + schema: { + type: "object", + properties: { note: { type: "string", file_uploadable: false } }, + }, + }, + { + where: "additionalProperties is open", + schema: { type: "object", additionalProperties: true }, + }, + { + where: "additionalProperties is closed", + schema: { type: "object", additionalProperties: false }, + }, + ]; + + for (const { where, schema } of offered) { + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + expect({ where, offered: listed.map((tool) => tool.name) }).toEqual({ + where, + offered: ["GMAIL_FETCH_EMAILS", "GMAIL_STAGES_A_FILE"], + }); + } + }); + test("the schema a model is shown is the one the SDK handed over, unaltered", async () => { /* * A CHARACTERIZATION TEST, and it passed before the claim beside `inputParameters` was @@ -407,6 +557,18 @@ describe("listing an app's actions", () => { examples: [{ query: "is:unread" }], "x-openbot-probe": "kept", }; + /* + * SNAPSHOTTED BEFORE THE CALL, because comparing the answer to `schema` compares it to the very + * object the stub handed over. `toEqual` between two references to one object holds whatever + * happened in between, so an in-place `delete` inside `listTools` — a walk that pruned what it + * read — passed this test unchanged. Proven: one added `delete` of a root keyword in the map + * left all 44 tests green. + * + * The snapshot is what the SDK handed over. The first assertion is that a model is shown that; + * the second is that the vendor's own object still IS that, because a module returning a + * faithful copy while wrecking the original would corrupt every later reader of one listing. + */ + const asHandedOver = structuredClone(schema); useComposioClient( recording({ listActions: async () => [{ ...GMAIL_READ, inputParameters: schema }], @@ -414,7 +576,8 @@ describe("listing an app's actions", () => { ); const [tool] = await listTools({ url: "composio://gmail" }); - expect(tool?.inputSchema).toEqual(schema); + expect(tool?.inputSchema).toEqual(asHandedOver); + expect(schema).toEqual(asHandedOver); }); test("an action with no schema is still listed, with an open one", async () => { From 7f357e587146f5d230fbeb358c59b476446ddd95 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:28:36 +0200 Subject: [PATCH 78/93] Read the vendor's answer only where an unreadable one can still be reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ways this transport mishandled what Composio said back. THE ANSWER WAS DEREFERENCED OUTSIDE ITS GUARD, at three sites. `actions.length` and `actions.filter` sit after the try that wraps `listActions`, and `reportedFailure(answer, …)` sits after the one that wraps `execute`. A client resolving null therefore propagated `null is not an object (evaluating 'actions.length')` into the row's `lastError` for an administrator to read, and threw a `TypeError` straight out of `callTool` — which this module documents as never throwing and `store.ts` relies on not throwing, because an exception ends a person's turn mid-run with nothing said and nothing audited. `ComposioActions` is our own projection and its adapter is unwritten, so a return type is not a promise about what resolves. Both shapes are settled now while a sentence can still be written, elements included: `[null]` is the same failure one level down. AN `error` BESIDE `successful: true` WAS DROPPED AND AUDITED AS A SUCCESS. `ToolExecuteResponseSchema` spells the two as independent required fields and `transformToolExecuteResponse` copies both off the wire (`@composio/core` 0.18.1, `src/models/Tools.ts:215-222`), so the combination is a shape the vendor's own schema permits. Taking the sentence as a failure is the vendor's arithmetic rather than a house rule: where the SDK derives the flag it writes `successful: !response.error` (`:1247`). A WHITESPACE-ONLY VERSION WAS TRUTHY WHEN RECORDED AND EMPTY WHEN SENT, so it was written to `mcp_tools` as a version this deployment believed it held and was then permanently uncallable, with a refusal naming a refresh that records the same blank again. It is trimmed at both ends now. `vendorSentence` had the same split: it trimmed in its guard and returned the padded string. `listingSentence` DID NOT FILTER THE VENDOR'S PLACEHOLDER the way `callTool` does, so "Error executing the tool X" reached the Plugins page — where the reader had just asked to refresh that very app. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 106 ++++++++++++-- server/tests/composio-transport.test.ts | 184 ++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 9446c0dff..325c3b926 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -389,6 +389,35 @@ export async function listTools(connection: { throw new Error(listingSentence(toolkit, error), { cause: error }); } + /* + * NOTHING IS READ OFF THE ANSWER UNTIL IT IS A LIST, and the check is here rather than assumed + * from the type. `ComposioActions` is this module's own projection, the adapter that would + * satisfy it has not been written, and a return type is not a promise about what resolves at + * runtime — a client that answers null, or a bare envelope with the array one level down, is a + * mistake this file will meet before a type checker does. + * + * Both reads below sit OUTSIDE the try that wraps the vendor's call, so before this guard + * `actions.length` propagated `null is not an object (evaluating 'actions.length')` — which is + * the string `refreshTools` writes into the row's `lastError` for an administrator to read. What + * this path owes that reader is a sentence naming the app and saying nothing was lost, so the + * shape is settled while such a sentence can still be written. + * + * THE ELEMENTS ARE CHECKED TOO, AND FOR THE SAME REASON RATHER THAN A DIFFERENT ONE. `[null]` + * reaches `action.inputParameters` in the filter below and `action.slug` in the map, both + * outside the try; it is the identical failure one level down, so it is answered here rather + * than left to produce a different unreadable message. What is checked is only that each + * element is an object — this module does not validate the vendor's schema, and an action + * missing a field it does not have is the vendor's business. + */ + if ( + !Array.isArray(actions) || + actions.some((action) => schemaNode(action) === null) + ) { + throw new Error( + `Composio did not answer with an action list for ${toolkit}: what came back was not a list of actions at all. Nothing was refreshed and the actions already recorded for this app are kept.`, + ); + } + if (actions.length >= LISTING_LIMIT) { /* * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. @@ -430,13 +459,22 @@ export async function listTools(connection: { .filter((action) => !stagesAFile(action.inputParameters)) .map((action) => { const { effect, destructive } = effectOf(action.tags); + /* + * TRIMMED HERE BECAUSE IT IS TRIMMED AT THE OTHER END. {@link callTool} trims the recorded + * version and refuses an empty one, so a whitespace-only string that counted as a version + * was written to `mcp_tools` as a version this deployment believes it holds and was then + * permanently uncallable — and the refusal its caller reads names a refresh, which records + * the same blank again. Recording exactly what `callTool` will send is what closes that + * loop; a blank becomes no version, which is the state whose refusal says so truthfully. + */ + const version = action.version?.trim(); return { name: action.slug, description: action.description ?? "", inputSchema: action.inputParameters ?? {}, effect, destructive, - ...(action.version ? { version: action.version } : {}), + ...(version ? { version } : {}), }; }); } @@ -457,13 +495,20 @@ export async function listTools(connection: { * Null when there is no such sentence, which leaves the caller to choose a fallback rather than * inventing one here. That choice is not simply "the thrown message": the thrown message is often the * placeholder above, and passing it on tells the reader nothing. See {@link unexplained}. + * + * TRIMMED ON THE WAY OUT AND NOT ONLY IN THE GUARD. The two used to disagree — the guard measured a + * trimmed string and the return handed back the padded one — so the decision the function had + * already made about the string was thrown away at the last line. What comes out is read by a person + * off an admin page, put in front of a model, and measured by {@link cap}, and in the third of those + * the padding is counted against somebody's context window. */ export function vendorSentence(error: unknown): string | null { const cause = (error as { cause?: unknown } | null | undefined)?.cause; const outer = (cause as { error?: unknown } | null | undefined)?.error; const inner = (outer as { error?: unknown } | null | undefined)?.error; const message = (inner as { message?: unknown } | null | undefined)?.message; - return typeof message === "string" && message.trim() !== "" ? message : null; + const sentence = typeof message === "string" ? message.trim() : ""; + return sentence === "" ? null : sentence; } /** @@ -509,6 +554,11 @@ function isSchemaMismatch(error: unknown): boolean { * The schema case names the fix, because it is a vendor change rather than a misconfiguration: the * answer arrived and this deployment's copy of their SDK would not accept it, so nothing an * administrator can do to this row will help and upgrading the package will. + * + * THE PLACEHOLDER IS REFUSED HERE ON THE SAME GROUNDS {@link callTool} REFUSES IT, which is the + * half this function was missing. "Error executing the tool X" names only the thing the reader + * asked for; on this path they asked to refresh an app, so it is the one fact they already had. + * Falling through to the app's name at least tells them which row went wrong. */ function listingSentence(toolkit: string, error: unknown): string { if (isSchemaMismatch(error)) { @@ -517,7 +567,7 @@ function listingSentence(toolkit: string, error: unknown): string { const thrown = error instanceof Error ? error.message.trim() : ""; return ( vendorSentence(error) ?? - (thrown === "" + (thrown === "" || VENDOR_PLACEHOLDER.test(thrown) ? `Composio did not answer with an action list for ${toolkit}.` : thrown) ); @@ -582,12 +632,30 @@ function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { } /** - * What the vendor said about its own call, read from the field its schema requires it to send. - * - * The criterion is that the vendor SAID the call did not succeed, which is `successful === false` and - * not a falsy `successful`. An absent field is not the vendor reporting a failure — the schema makes - * it impossible from the real client, and reading it as a failure would turn a projection looser than - * the schema into a refusal of a call that worked. + * What the vendor said about its own call, read from BOTH fields its schema requires it to send. + * + * EITHER ONE CAN REPORT A FAILURE, and reading only the flag dropped the other. `successful === + * false` is the plain case. The second is an `error` sentence arriving beside `successful: true`: + * `ToolExecuteResponseSchema` spells the two as independent required fields and correlates them + * nowhere, and `transformToolExecuteResponse` copies both straight off the wire (`@composio/core` + * 0.18.1, `src/models/Tools.ts:215-222`), so that combination is a shape the vendor's own schema + * permits. Keyed on the flag alone it was audited as `mcp.call_succeeded` and the one sentence + * saying what went wrong was shown to nobody. + * + * TAKING THE ERROR AT ITS WORD IS THE VENDOR'S OWN ARITHMETIC rather than a rule invented here: + * where the SDK has to derive the flag itself it writes `successful: !response.error` (`:1247`). It + * is also the reading this file already applies to a vendor contradicting itself — see + * {@link effectOf} on `destructiveHint` beside `readOnlyHint`. + * + * WHICH IS EQUALLY WHY THE CRITERION IS A NON-EMPTY SENTENCE. By that same line `""` is a success, + * so an empty `error` is the vendor saying nothing went wrong in the least committal way open to it. + * Whitespace is read as empty too, and that part is this file's own reading rather than the SDK's — + * it matches {@link vendorSentence}, because a blank sentence beside an explicit `successful: true` + * would otherwise become a refusal saying only that the call failed and nobody said why. + * + * AND `successful !== false` RATHER THAN A FALSY `successful`, because an absent field is not the + * vendor reporting anything: the schema makes it impossible from the real client, and reading it as + * a failure would turn a projection looser than the schema into a refusal of a call that worked. * * Null when there is nothing to report, so the caller can tell "succeeded" from "failed silently". */ @@ -595,8 +663,8 @@ function reportedFailure( answer: ComposioResult, toolName: string, ): string | null { - if (answer.successful !== false) return null; const sentence = typeof answer.error === "string" ? answer.error.trim() : ""; + if (answer.successful !== false && sentence === "") return null; return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) ? unexplained(toolName) : sentence; @@ -686,6 +754,24 @@ export async function callTool( ); } + /* + * NOTHING IS READ OFF THE ANSWER UNTIL IT IS AN ENVELOPE, and here the reason is stronger than + * the listing's. {@link callTool} is documented as never throwing and `store.ts` relies on that, + * so a shape this module did not expect has to become a refusal rather than an exception. + * `reportedFailure` reads `answer.successful` and was called from outside every try, so a client + * resolving null threw a `TypeError` straight out of here — ending a person's turn mid-run with + * nothing said and nothing audited, which is exactly what returning a result instead of throwing + * exists to prevent. + * + * A vendor fault it is not, so it does not get the vendor's words. This is the third kind of + * failure the comment above names: Composio answered and this deployment could not read it. + */ + if (typeof answer !== "object" || answer === null) { + return failure( + `${toolName} was sent to Composio and its client resolved, but this deployment could not read what it resolved with: it was not the { data, error, successful } envelope Composio's own schema requires.`, + ); + } + const reported = reportedFailure(answer, toolName); if (reported !== null) return failure(reported); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 0e2a113ea..96bc6db1c 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { + type ComposioAction, type ComposioActions, + type ComposioResult, callTool, effectOf, LISTING_LIMIT, @@ -269,6 +271,16 @@ describe("finding the vendor's own sentence", () => { expect(vendorSentence(nested("\n\t "))).toBeNull(); }); + test("the sentence comes back as it was measured, without its padding", () => { + // The guard trimmed and the return did not, so the one thing the function had already decided + // about the string was thrown away again. What comes out is a refusal in a model's context and + // a sentence in an audit row; leading newlines in both are this module's own untidiness, and + // the cap that measures the string measures the padding with it. + expect(vendorSentence(nested(" Gmail rejected the query.\n"))).toBe( + "Gmail rejected the query.", + ); + }); + test("a message that is not a string is not read as one", () => { // Nothing asserted the type guard either. Composio's payloads are somebody else's JSON, so the // field can be a number, an object or null; handed on unchecked, each of those reaches a model's @@ -727,6 +739,88 @@ describe("listing an app's actions", () => { expect(message).toContain("gmail"); } }); + + test("a listing failure carrying only the vendor's placeholder says something else", async () => { + /* + * `callTool` already refuses to pass "Error executing the tool X" on, and the listing path did + * not. Same string, same reader: `refreshTools` writes this sentence into the row's + * `lastError` and an administrator reads it off the Plugins page, where the name of the thing + * they asked to refresh is the one fact they already have. + */ + useComposioClient( + recording({ + listActions: async () => { + throw new Error("Error executing the tool GMAIL_FETCH_EMAILS"); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).not.toMatch(/error executing the tool/i); + expect(message).toContain("gmail"); + }); + + test("an answer that is not a list of actions throws a sentence, not a TypeError", async () => { + /* + * `ComposioActions` is OUR projection of the vendor, implemented by an adapter nobody has + * written yet, and TypeScript polices none of what a promise actually resolves to at runtime. + * A client that answers `null` — a 204, an SDK path that returns before assigning, a mock in + * somebody's staging deployment — used to reach `actions.length` and `actions.filter` outside + * the try that wraps the vendor's call, so what propagated was `null is not an object`. That + * lands verbatim in `lastError` on the Plugins page and tells an administrator nothing about + * which app or what to do, which is the whole reason this path throws sentences. + */ + // `[null]` is the same failure one level down: it clears `Array.isArray` and then reaches + // `action.inputParameters` in the filter, which is outside that try as well. + for (const shape of [null, undefined, { items: [] }, "gmail", [null]]) { + useComposioClient( + recording({ + listActions: async () => shape as unknown as ComposioAction[], + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).toContain("gmail"); + expect(message).not.toMatch(/is not an object|is not a function/i); + } + }); + + test("a version made only of whitespace is recorded as no version at all", async () => { + /* + * TRIMMED ON THE WAY IN BECAUSE IT IS TRIMMED ON THE WAY OUT. `callTool` trims the recorded + * version and refuses an empty one, so a blank string that counts as a version here is written + * to `mcp_tools` as a version this deployment believes it has and is then permanently + * unusable — and the refusal the caller gets names a refresh, which rewrites the same blank. + * That is exactly the loop the test above reasons about, reached by recording rather than by + * the vendor publishing nothing. + */ + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_BLANK", tags: ["readOnlyHint"], version: " " }, + { + slug: "GMAIL_PADDED", + tags: ["readOnlyHint"], + version: " 20260903_00\n", + }, + ], + }).client, + ); + + const [blank, padded] = await listTools({ url: "composio://gmail" }); + + expect(Object.keys(blank ?? {})).not.toContain("version"); + // Recorded as the version `callTool` will actually send, rather than as one it has to repair. + expect(padded?.version).toBe("20260903_00"); + }); }); describe("calling one action", () => { @@ -1140,4 +1234,94 @@ describe("calling one action", () => { expect(result.text).toMatch(/could not turn that answer into text/i); expect(result.text).toContain("GMAIL_FETCH_EMAILS"); }); + + test("an answer reporting an error while claiming success is a failure", async () => { + /* + * `ToolExecuteResponseSchema` spells `error` and `successful` as two independent required + * fields and correlates them nowhere; `transformToolExecuteResponse` copies both straight off + * the wire (`@composio/core` 0.18.1, `src/models/Tools.ts:215-222`). So the combination is a + * shape the vendor's own schema permits, and keying only on `successful === false` dropped the + * one sentence in it that says anything — audited as `mcp.call_succeeded`, with the failure + * handed to the model as though it were content. + * + * The strict reading is the safe one and it is also the vendor's: where the SDK has to derive + * the flag itself it writes `successful: !response.error` (`src/models/Tools.ts:1247`), so a + * present error IS a failure by their own arithmetic. Same rule as `effectOf` uses for + * contradictory labels — both at once is somebody else's bug, and we take the strict branch. + */ + useComposioClient( + recording({ + execute: async () => + answered( + { messages: [{ id: "m1" }] }, + { + successful: true, + error: "Gmail rejected the query: invalid search syntax.", + }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + // The data must not be handed over as content beside a reported failure. + expect(result.text).not.toContain("m1"); + }); + + test("an empty error beside a success is still a success", async () => { + // The other side of the rule, and the reason it is worded as a SENTENCE rather than as a + // present field: `successful: !response.error` treats `""` as success, so an empty string is + // the vendor saying nothing went wrong in the least committal way available to it. + useComposioClient( + recording({ + execute: async () => + answered({ messages: [] }, { successful: true, error: "" }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(false); + expect(result.text).toBe(JSON.stringify({ messages: [] }, null, 2)); + }); + + test("an answer that is not an envelope refuses rather than throwing", async () => { + /* + * THE NEVER-THROW CONTRACT, asserted against the shape that broke it. This module documents a + * failure as a RESULT and `store.ts` relies on it: a model is mid-run with a person waiting, + * and an exception ends the turn with nothing said and nothing audited. + * + * `reportedFailure(answer, …)` read `answer.successful` outside every try, so a client + * resolving `null` threw a `TypeError` straight out of `callTool`. Like the listing case, this + * is a shape `ToolExecuteResponseSchema` forbids and `ComposioActions` cannot police — the + * projection is ours, the adapter is unwritten, and a runtime resolution is not a type. + */ + for (const shape of [null, undefined, "ok", 7]) { + useComposioClient( + recording({ + execute: async () => shape as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toMatch(/is not an object|undefined is not/i); + } + }); }); From d1afeba50153e266fa0d943c062bf334f5aaadd6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:28:53 +0200 Subject: [PATCH 79/93] Send the app the url names, so the gate and the call are about one fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toolkitOf`'s answer was validated and then discarded: `execute` received the action slug alone, so the app a brokered call ran against was implied by the slug a LISTING recorded, while the person's connection had been gated on the app the url names NOW. A url edited between a refresh and a call was therefore gated on one app and run against another — somebody's Slack connection satisfying the gate for a Gmail action that still runs in their Gmail. That is the branch's central guarantee, and a check performed and dropped is not one. The app is an argument of the call now, in a named record rather than four positional strings so a transposition cannot pass silently. It is asserted to FOLLOW the url and not merely to be present, which is what a discarded check could never show. THE VENDOR'S WIRE GENUINELY CANNOT CARRY IT, so the obligation is written into the projection where an implementation has to meet it. `ToolExecuteParams` has no toolkit field and the client's method takes the slug alone — `execute(toolSlug, params, options)` over `arguments`, `user_id`, `version` and connection overrides (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:41`, `:480-532`) — and the core SDK sends exactly that (`@composio/core` 0.18.1, `src/models/Tools.ts:1013`). What an implementation can do is refuse a mismatch, and it has what it needs to: `tools.execute` already resolves the tool by slug before running it (`:1163`, resolver at `:693`) and the resolved tool carries the app the vendor will run it against as `Tool.toolkit.slug` (`src/types/tool.types.ts:189`). Disagreement is required to throw, which `callTool` already turns into a refusal with a sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 49 ++++++++++++++++++++-- server/tests/composio-transport.test.ts | 54 ++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 325c3b926..6f250a1ab 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -147,10 +147,42 @@ export type ComposioActions = { toolkit: string, page: { limit: number }, ): Promise; + /** + * One action, of one app, as one person, at one version. + * + * THE APP IS PART OF THE CALL AND NOT A CHECK BESIDE IT, which is the whole reason this takes a + * named record rather than four strings. {@link callTool} resolves the app from the connection's + * url and the brokered gate in `./access` looks a person's `composio_connections` row up by that + * same name — and then the call used to go out as the slug alone. A slug is what a LISTING + * recorded, so a url edited between a refresh and a call was gated on the app it names NOW and + * run against the app it named THEN: somebody's Slack connection satisfying the gate for a Gmail + * action that still runs in their Gmail. The gate and the call have to be about one fact. + * + * THE VENDOR'S WIRE CANNOT CARRY THE PAIR, so the obligation is written down here instead. The + * REST parameters have no toolkit field and the client's method takes the slug alone — + * `execute(toolSlug, params, options)` with `ToolExecuteParams` of `arguments`, `user_id`, + * `version` and connection overrides (`@composio/client` 0.1.0-alpha.76, + * `resources/tools.d.ts:41` and `:480-532`) — and the core SDK sends exactly that, + * `clientWithoutRetries.tools.execute(tool.slug, executeBody)` (`@composio/core` 0.18.1, + * `src/models/Tools.ts:1013`). + * + * SO AN IMPLEMENTATION MUST REFUSE A MISMATCH RATHER THAN FORWARD ONE, and it has what it needs + * to. `tools.execute` already resolves the tool by slug before running it + * (`src/models/Tools.ts:1163`, resolver at `:693`), and the resolved tool carries the app the + * vendor will actually run it against as `Tool.toolkit.slug` (`src/types/tool.types.ts:189`). + * Where that disagrees with `call.toolkit`, an implementation is required to throw instead of + * executing — which {@link callTool} already turns into a refusal with a sentence, because a + * throw out of here is the vendor-reported failure it is written to catch. + */ execute( - slug: string, - userId: string, - version: string, + call: { + /** The app the connection's url names, resolved by {@link toolkitOf} at call time. */ + toolkit: string; + slug: string; + /** Never from `args`. See the module comment. */ + userId: string; + version: string; + }, args: Record, ): Promise; }; @@ -681,6 +713,12 @@ function reportedFailure( * mid-run with a person waiting; an exception ends the turn with nothing said, and the refusal is in * the audit trail either way. * + * THE APP THIS CALL RUNS AGAINST IS THE ONE THE URL NAMES RIGHT NOW, and it goes out WITH the call + * rather than being checked beside it. `toolkitOf`'s answer used to be validated and then dropped, + * which left the brokered gate and the vendor's call resting on two different facts — the app the + * url names today, and the app whose listing recorded the slug. See {@link ComposioActions.execute} + * for why the pair has to travel together and what an implementation owes it. + * * THREE KINDS OF FAILURE, all of them `isError: true` and each with its own sentence, because * `store.ts` records that sentence beside the audit row: this transport refused before dialling, the * vendor reported a failure — by throwing, or in the `successful` field of a 200 answer — or the @@ -742,7 +780,10 @@ export async function callTool( */ let answer: ComposioResult; try { - answer = await installed.execute(toolName, userId, version, rest); + answer = await installed.execute( + { toolkit, slug: toolName, userId, version }, + rest, + ); } catch (error) { // The vendor's own sentence when there is one, because a generic message costs a diagnosis. const thrown = error instanceof Error ? error.message.trim() : ""; diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 96bc6db1c..72409569d 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -30,7 +30,12 @@ import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; afterEach(() => useComposioClient(null)); -type Recorded = { slug: string; userId: string; version: string }; +type Recorded = { + toolkit: string; + slug: string; + userId: string; + version: string; +}; /** * An answer in the shape `ToolExecuteResponseSchema` actually permits. @@ -87,8 +92,8 @@ function recording(answers: Partial = {}): { listActions: answers.listActions ?? (async () => []), execute: answers.execute ?? - (async (slug, userId, version) => { - calls.push({ slug, userId, version }); + (async (call) => { + calls.push({ ...call }); return answered({ ok: true }); }), }, @@ -836,6 +841,7 @@ describe("calling one action", () => { expect(calls).toEqual([ { + toolkit: "gmail", slug: "GMAIL_FETCH_EMAILS", userId: "user_asker", version: "20260903_00", @@ -844,24 +850,61 @@ describe("calling one action", () => { expect(result.isError).toBe(false); }); + test("the app goes out with the call, and follows the url when the url changes", async () => { + /* + * THE DEEPEST HOLE THIS TRANSPORT HAD. `toolkitOf` resolved the app, `accessFor` gated the + * person's `composio_connections` row on it, and then the call went out as the slug alone — + * and a slug is what a LISTING recorded, not what the url says now. A url edited between a + * refresh and a call was therefore gated on the app it names today and run against the app it + * named when the tools were last read: a person who connected Slack satisfying the gate for a + * Gmail action that still runs in their Gmail. + * + * Composio's wire cannot carry the pair — `ToolExecuteParams` has no toolkit field and + * `tools.execute(toolSlug, params)` takes the slug alone (`@composio/client` 0.1.0-alpha.76, + * `resources/tools.d.ts:480-493` and `:41`) — so what binds them here is that the app is an + * argument of the call this module makes and an implementation has to reconcile it with the + * tool it resolves. Asserting it is passed asserts the implementation was handed the fact it + * needs; asserting it FOLLOWS the url is the part a check performed and then discarded could + * never show, and discarding it was the defect. + */ + const { client, calls } = recording(); + useComposioClient(client); + + for (const app of ["gmail", "slack"]) { + await callTool( + { url: `composio://${app}`, actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + } + + expect(calls.map((call) => call.toolkit)).toEqual(["gmail", "slack"]); + }); + test("the version is not passed on to the vendor as an argument", async () => { const seen: Record[] = []; useComposioClient( recording({ - execute: async (_slug, _userId, _version, args) => { + execute: async (_call, args) => { seen.push(args); return answered({}); }, }).client, ); + // Held in a variable rather than written inline, because `seen` showing the version absent + // shows it only of whatever object the module chose to pass on. Deleting the key from the + // CALLER'S object and forwarding that satisfies the assertion below while destroying the + // record the call path still holds — the same identity-for-value mistake the schema test had. + const args = { query: "is:unread", __version: "20260903_00" }; await callTool( { url: "composio://gmail", actorId: "user_asker" }, "GMAIL_FETCH_EMAILS", - { query: "is:unread", __version: "20260903_00" }, + args, ); expect(seen).toEqual([{ query: "is:unread" }]); + expect(args).toEqual({ query: "is:unread", __version: "20260903_00" }); }); test("a call with no recorded version refuses rather than guessing one", async () => { @@ -909,6 +952,7 @@ describe("calling one action", () => { // and the only structural defence is that the argument name is never read. expect(calls).toEqual([ { + toolkit: "gmail", slug: "GMAIL_FETCH_EMAILS", userId: "user_asker", version: "20260903_00", From 8cea54bf41b73bb548544adece1a3b5bf5484d6f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:20:11 +0200 Subject: [PATCH 80/93] Key the anonymous connection row on its app, so no file sweeps another's `composio_connections.user_id` is notNull and notNull does not exclude the empty string, so `(app, "")` is a legal row and two suites write one. Neither owned it. `plugin-store.integration.test.ts` refused to run on any `""` row whatever app it named, and deleted every `""` row whatever app it named; `composio-connections.test.ts` writes one against its own run-suffixed app. A run of the second killed before its cleanup therefore stranded a row that made all 81 tests in the first refuse permanently, and the two running together had the first deleting the second's fixture mid-test. Both now key on the PAIR. The guard refuses on `("gmail", "")` and nothing else, the cleanup deletes `("gmail", "")` and nothing else, and the same pair joins the sweep in `freshDatabase` so a row stranded earlier in a run is gone before the next test looks. A witness row at a suffixed app, inserted and removed by the test itself, is what fails if either delete widens again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-connections.test.ts | 34 +++++ server/tests/plugin-store.integration.test.ts | 119 +++++++++++++++--- 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 59881107c..33c274dbb 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -313,6 +313,13 @@ test("retiring the same person twice retires nothing the second time", async () * THE ANONYMOUS ACTOR OWNS NOTHING, and `notNull` does not exclude the empty string, so a row at * `(toolkit, "")` is legal. Retiring "nobody" must not be what deletes it — that would be an * unattributed offboarding reaching a row it cannot possibly own. + * + * WHOSE ROW THIS IS, since the actor half of the key names nobody. The app half does: {@link + * toolkit} carries this run's suffix, so the sweep in `clean` takes this row by the same clause it + * takes the asker's by, and no other file can arrive at the pair by guessing. That is the whole of + * the ownership — a delete keyed on `user_id = ''` alone would reach every app's anonymous row at + * once, which is how this fixture came to be removed mid-run by another file, and how a run of + * this file that died before its cleanup came to refuse every test in that one. */ test("retiring nobody retires nothing and leaves the anonymous row alone", async () => { await seedApp({ connect: false }); @@ -322,6 +329,33 @@ test("retiring nobody retires nothing and leaves the anonymous row alone", async expect(await connectedToolkitsFor("")).toEqual([toolkit]); }); +/** + * The fixture above is taken back by the same sweep every other row here is, and by nothing wider. + * + * CRITERION. After the sweep, this run holds no `composio_connections` row at all — the one at the + * anonymous actor included, which none of the person ids that sweep names would reach. + * + * REASON. Brokered connections are removed here by toolkit, so the anonymous row is already + * covered and needs no second, broader delete to reach it. Asserted rather than read off the code, + * because the tempting spelling for "take the anonymous row too" is `user_id = ''`, which is every + * app at once: the sweep that lands on another file's fixture. A test that reddens the moment this + * file needs a wider delete is what keeps that spelling out. + */ +test("the sweep takes this run's anonymous row without reaching by actor", async () => { + await seedApp({ connect: false }); + await database.insert(composioConnections).values({ toolkit, userId: "" }); + expect(await connectedToolkitsFor("")).toEqual([toolkit]); + + await clean(); + + expect( + await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)), + ).toEqual([]); +}); + /** * REMOVING THE APP. The second act that has to end a brokered connection. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b8183aafa..0656349e7 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -9,7 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; -import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, like, or, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -265,20 +265,35 @@ beforeAll(async () => { .from(agents) .where(eq(agents.id, "bot_helper")), /* - * The anonymous actor is one of these ids too. `composio_connections.user_id` is notNull and - * notNull does not exclude the empty string, so `("gmail", "")` is a row a deployment can - * legally hold — which is the whole point of the test that inserts one — and the delete that - * takes it back again runs inline, outside `freshDatabase` and outside every other sweep. + * The anonymous actor is one of these ids too, but only at the app this file writes it + * against. + * + * CRITERION. This guard refuses on the PAIR `("gmail", "")` and on no other row at the + * anonymous actor, because that pair is the only one this file inserts and the only one it + * deletes. + * + * REASON. `composio_connections.user_id` is notNull and notNull does not exclude the empty + * string, so `("gmail", "")` is a row a deployment can legally hold — which is the whole + * point of the test that inserts one. Asked as `user_id = ''` alone, this also caught the + * anonymous row `composio-connections.test.ts` writes against its OWN run-suffixed app: a + * run of that file killed between its insert and its cleanup left a row nobody here owns, + * and every one of this file's tests then refused for good over it. Keyed on the pair, a + * stranded row belonging to another file is simply not this one's business. */ database - .select({ userId: composioConnections.userId }) + .select({ + toolkit: composioConnections.toolkit, + userId: composioConnections.userId, + }) .from(composioConnections) .where( - inArray(composioConnections.userId, [ - "user_asker", - "user_leaver", - "", - ]), + or( + inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ), ), /* * The person, who was missing from this guard entirely. @@ -297,7 +312,8 @@ beforeAll(async () => { ...configuredServers.map((row) => `the mcp_servers row '${row.id}'`), ...existingBots.map((row) => `the Bot '${row.id}'`), ...existingConnections.map( - (row) => `the composio_connections row for '${row.userId}'`, + (row) => + `the composio_connections row ('${row.toolkit}', '${row.userId}')`, ), ...existingPeople.map((row) => `the person '${row.id}'`), ]; @@ -3554,9 +3570,28 @@ async function freshDatabase(): Promise { // first test below is about, so nothing else removes these rows — and a delete by toolkit alone // would take every person's Gmail connection, leaving one orphaned at the broker with no local row // left to find it by. Only the two people this file invents. - await database - .delete(composioConnections) - .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + await database.delete(composioConnections).where( + or( + inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + /* + * And the one pair at the anonymous actor this file writes, which no person id names. + * + * CRITERION. Exactly `("gmail", "")`, never `user_id = ''` across every app: the second + * spelling reaches the anonymous row another file owns at its own run-suffixed app. + * + * REASON. The test that inserts this pair takes it back in a `finally`, which covers a + * failed assertion and not a killed process — and the row it would otherwise leave is + * precisely what the guard at the top of this file refuses on. Swept here, a pair stranded + * earlier in this same run is gone before the next test looks at it; a pair that was + * already there when the run started is still the guard's to refuse, because at that point + * nothing has established it is ours. + */ + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ), + ); // The person the connection outlives, who is a row in `users` like anybody else. Reached only // through the check at the top of this function, because there is no suffix on this id to tell a // fixture apart from somebody's account and ten cascades sit behind the difference. @@ -3818,6 +3853,22 @@ test("a Composio call with nobody attributed is refused even when a connection r await database .insert(composioConnections) .values({ toolkit: "gmail", userId: "" }); + /* + * A second anonymous row, at an app this file has nothing to do with. + * + * CRITERION. Whatever removes the row above must leave this one exactly where it is. + * + * REASON. The cleanup below used to be `user_id = ''`, which is every app at once. That reached + * the anonymous row `composio-connections.test.ts` writes against its own run-suffixed app — + * deleting another file's fixture out from under it when the two run together — and it is the + * other half of the same confusion the guard at the top of this file suffered from. Suffixed, so + * this row is provably this run's to insert and to take away again, and so no real deployment + * row can be what the assertion below is reading. + */ + const unrelatedApp = `sweep_witness_${suite}`; + await database + .insert(composioConnections) + .values({ toolkit: unrelatedApp, userId: "" }); try { await expect( @@ -3830,12 +3881,44 @@ test("a Composio call with nobody attributed is refused even when a connection r ).rejects.toThrow(/not attributed to anybody/i); expect(reached).toEqual([]); + + // Here rather than only in `freshDatabase`, so the row is gone the moment this test is done + // with it and the assertion below has something to read. Keyed on the PAIR either way: the app + // is what makes this row this file's, and the anonymous actor on its own names nobody's. + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ); + + // What the cleanup took, and what it did not. Asked as two facts about this list rather than + // as the whole of it, deliberately: a third app's anonymous row is somebody else's business, + // and a test that failed because one existed would be the same over-reach in assertion form. + const anonymous = ( + await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, "")) + ).map((row) => row.toolkit); + expect(anonymous).not.toContain("gmail"); + expect(anonymous).toContain(unrelatedApp); } finally { - // Inline, because `freshDatabase` clears this table by the two people this file invents and the - // anonymous actor is neither — so nothing else in the run would ever remove this row. + // Both, so a failed assertion above still leaves the table as this test found it. Each is keyed + // on an app this run named, which is what makes the deletes this run's to make. + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, unrelatedApp)); await database .delete(composioConnections) - .where(eq(composioConnections.userId, "")); + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ); } }); From ffb2fee2a6639db1928871fc3beba0b4aa4eba45 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:21:58 +0200 Subject: [PATCH 81/93] Cover the empty-listing guard through a vendor that actually answers nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 added the guard that refuses to commit an empty listing over actions already held; wave 2 then made the Composio transport throw rather than answer `[]`, which is correct and routed wave 1's three tests around the guard into the vendor `catch` several lines earlier. `if (listed.length === 0)` could be replaced with `if (false)` and all 89 tests stayed green: only the "commit when nothing is held" half was covered, and the data-loss half was not. A second describe drives the guard through a stub that answers `[]` — a vendor's own answer, which no throw can stand in for — and asserts the three things the guard promises: the held action survives with its `version`, the row says the actions were kept and takes no refresh stamp, and no grant is withdrawn or filed as unadvertised. All three redden under `if (false)`. The describe above it stated as its premise that the transport answers `[]` rather than throwing, which stopped being true; it is retitled and rewritten to what it actually covers. Its "not cleared" assertion read `not.toBeNull()` against a `lastError` the refresh had overwritten, so it passed on a different string than the one it was about; it now names the transport's own sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 153 ++++++++++++++++-- 1 file changed, 141 insertions(+), 12 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 0656349e7..cc1e3e2dd 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -4657,19 +4657,25 @@ test("a granted Composio action that the vendor withdrew is still shown as grant }); /* - * A refresh that listed nothing, on the path every real deployment takes. + * A refresh the transport REFUSED, on the path every real deployment takes. * * NOTHING IN THE SHIPPED PRODUCT CALLS `useComposioClient`, so `installed` is null on every live - * install and the Composio transport answers `[]` — not a throw — for want of a client to ask with. - * The tests below install no stub, which is that state exactly rather than a fiction about it. + * install and `composio.listTools` throws for want of a client to ask with. The tests below install + * no stub, which is that state exactly rather than a fiction about it. * - * A wholesale replace on that answer deletes what the last real listing recorded, and `version` is - * the column that cannot be reconstructed: `callTool` refuses without it, so an app whose actions - * were dropped this way stops working for everybody until somebody presses Refresh on a deployment - * that can actually reach the vendor. The grants survive the delete pointing at rows that no longer - * exist, which is invisible on the Plugins page and revived by the next refresh that works. + * THE PREMISE THIS DESCRIBE USED TO STATE — that the transport answers `[]` rather than throwing — + * WAS TRUE AND IS NOT. It was corrected at the seam, deliberately: a transport that could not ask + * anybody must throw, because an empty answer is indistinguishable from an app that genuinely + * publishes nothing. These three cases therefore land in `refreshTools`'s vendor `catch`, which + * records the sentence and returns before the replace — and they say nothing whatever about the + * empty-listing guard below it, which is what the describe after this one is for. Two suites + * asserting the same three outcomes through different branches is how that guard came to be + * deletable with every test still green. + * + * What is under test here is the catch branch's own promise: a listing that could not be made + * leaves every recorded action where it is, stamps no refresh, and withdraws nothing. */ -describe("a refresh whose transport could not ask anybody", () => { +describe("a refresh whose transport refused to ask anybody", () => { test("leaves the actions the app already advertises, with what the vendor said about them", async () => { const { store, database } = await freshStore(); await seedComposioGmail(database, store); @@ -4710,9 +4716,18 @@ describe("a refresh whose transport could not ask anybody", () => { .from(mcpServers) .where(eq(mcpServers.id, "gmail")); - // Not cleared. An empty answer from a transport that reached no vendor is not evidence that - // whatever was wrong before has been put right. - expect(row?.lastError).not.toBeNull(); + /* + * The transport's own sentence, named rather than merely counted as present. + * + * `not.toBeNull()` was what this asserted, and the refresh had in fact OVERWRITTEN the value + * the test set up two lines earlier — so "not cleared" passed on a different string than the + * one it was about, and would have gone on passing had the column been filled with anything at + * all, the empty-listing guard's sentence included. Which sentence is here is the whole + * difference between "nobody could be asked" and "the app was asked and offers nothing", and + * those send an operator to different places. + */ + expect(row?.lastError).toContain("Composio is not configured"); + expect(row?.lastError).not.toBe("The vendor would not answer."); // And no refresh stamp, because nothing was listed: the column says when this deployment last // learned what the app offers, and it did not learn it here. expect(row?.toolsRefreshedAt).toBeNull(); @@ -4769,6 +4784,120 @@ describe("a refresh whose transport could not ask anybody", () => { }); }); +/** + * THE VENDOR ITSELF ANSWERING NOTHING, which is the state the empty-listing guard exists for. + * + * WHAT THIS COVERS THAT NOTHING ELSE DOES. The guard sits after the vendor `catch` and before the + * wholesale replace, and only a listing that was actually MADE and came back empty reaches it. The + * describe above cannot: its transport throws, so it returns from the catch several lines earlier. + * With those three cases routed around it, `if (listed.length === 0)` could be replaced by + * `if (false)` — deleting the guard outright — and the whole suite stayed green. Everything below + * reddens under that mutation, which is the only thing that makes the guard's presence a fact + * about this codebase rather than a comment in it. + * + * WHY IT MATTERS. The replace is a delete and an insert, so committing an empty answer deletes + * every `mcp_tools` row for the app and takes `effect`, `destructive` and `version` with it. + * `version` cannot be reconstructed — `callTool` refuses an action without one — so a refresh that + * reported success broke every later call, with the grants left pointing at rows that no longer + * exist. A stub that answers `[]` is a vendor's honest answer and is exactly what an app that has + * been emptied at the broker looks like; keeping what is held is the only reading that is + * recoverable if it is wrong. + */ +describe("a refresh the vendor answered with no actions at all", () => { + /** A client that answers, and answers nothing — which no throw can stand in for. */ + function useEmptyAnsweringClient() { + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(), + }); + } + + test("keeps every action already recorded, with what the vendor said about them", async () => { + const { store, database } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + // The honest count is what is HELD, because nothing was replaced. Answering 0 here would tell + // the page the app offers nothing while the rows are still there. + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The version above all: it is what `callTool` sends, so losing it breaks every later call on + // an app the refresh reported as fine. + expect(rows).toEqual([ + { name: "GMAIL_FETCH_EMAILS", effect: "read", version: "20260903_00" }, + ]); + }); + + test("says the actions were kept, and does not stamp a refresh", async () => { + const { store, database } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + /* + * The sentence for THIS state and not the other one. The app answered, so nothing here may + * send an operator to check their configuration — that is the refused transport's sentence, + * and the describe above asserts that one. What this reader needs to know is that the app + * listed nothing and that the actions it holds were not deleted over it. + */ + expect(row?.lastError).not.toBeNull(); + expect(row?.lastError).toContain("kept rather than deleted"); + // No stamp: the column says when this deployment last learned what the app offers, and an + // answer it declined to believe is not it. + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("withdraws nothing and strands no grant", async () => { + const { store, database, auditStore } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Still offered and still not withdrawn. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_FETCH_EMAILS", + ]); + expect(gmail?.withdrawn).toEqual([]); + // Nor filed as having stopped being offered. That row is written from the listing, so an empty + // one committed would name every grant the app holds — the trail asserting a withdrawal on + // exactly the answer this deployment decided not to believe. + expect( + auditStore + .recorded() + .filter( + (event) => + (event.payload as { change?: string }).change === + "grants_not_advertised", + ), + ).toEqual([]); + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From 0fe17543b0417f88ba085cce8b0ae5d807ad19ca Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:24:54 +0200 Subject: [PATCH 82/93] Assert which MCP annotation is believed, and stop claiming none is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listTools` now reads `annotations.destructiveHint`, so an MCP listing can fill in `effect` and `destructive` — and the docblock in this file said the opposite: that those three columns are Composio's and every other transport "has to keep coming out of that insert as null and false". Its own test still passed, because its fixtures carry no annotations; a comment nothing can falsify is how this branch has repeatedly ended up with code written to match it. Narrowed to what that test is actually about. The new test states the asymmetry the transport implements. A tool declaring `destructiveHint` records `write` and `true`. Two tools declaring `readOnlyHint` — one on Notion's reviewed write list, one not — record NULL, which is the assertion that matters: both classify correctly whatever the column holds, so only an empty column says the hint was never read. That is what keeps `readOnlyHint` from becoming an opt-out for a server an administrator added by URL, where `classifyTool` answers from the column before it reaches "no reviewed list means everything is a write". The mock hands its fixtures back verbatim, so annotations already travelled at runtime and were only unspellable in `MCPToolDefinition`; the helper's parameter is widened rather than each fixture cast. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 119 +++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index cc1e3e2dd..4c01199d2 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -9,6 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import { and, asc, eq, gte, inArray, like, or, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; @@ -1843,7 +1844,16 @@ describe("refresh token rotation", () => { */ async function withMockedNotionListing( notionServerId: string, - tools: MCPToolDefinition[], + /* + * The mock's own tool shape, plus the annotations a real server publishes. + * + * `MCPToolDefinition` names only name, description and schema, and the mock hands whatever it was + * given straight back in its `tools/list` answer — so an annotation travels at runtime and is + * simply unspellable in the type. Widened here rather than cast at each fixture, because the + * hints are what `listTools` reads to decide an action's recorded effect, and a test about that + * decision should not be the one place a cast hides a shape drifting. + */ + tools: (MCPToolDefinition & { annotations?: ToolAnnotations })[], body: () => Promise, ) { const mock = new MCPMock(); @@ -2804,8 +2814,11 @@ describe("a dynamic client the vendor has evicted", () => { /** * The classification an MCP server has always had, over a real listing that really happened. * - * The three columns the refresh now writes are Composio's, and every other transport has to keep - * coming out of that insert as null and false. `classifyTool` consults the reviewed `writeTools` + * The three columns the refresh writes are the transports' to fill in, and an MCP server that + * annotates nothing fills in none of them — which is what every fixture in THIS test does, and + * so what it is about. It is no longer true of the transport in general: `listTools` reads + * `annotations.destructiveHint` and writes both `effect` and `destructive` from it, which the + * test below this one covers. `classifyTool` consults the reviewed `writeTools` * list BEFORE the recorded `effect` column, on the criterion that a recorded value may narrow what * a Bot is allowed and may never widen it — so a name the list covers stays a write whatever the * column says, and a value appearing here can no longer turn one of Notion's reviewed writes into a @@ -2891,6 +2904,106 @@ describe("a dynamic client the vendor has evicted", () => { ); }); + /** + * The one annotation this deployment believes, and the two it declines to. + * + * CRITERION. `destructiveHint === true` is recorded as `effect: "write"` and `destructive: true`. + * `readOnlyHint` is recorded as NOTHING AT ALL — not as `read`, not as a value overridden further + * down — whether or not the reviewed write list already covers the name. + * + * REASON. The SDK warns where it declares these hints that a client must not make tool-use + * decisions from annotations an untrusted server supplied, and the two hints are not symmetrical + * against that warning. `destructiveHint` can only move an action from read to write, so a server + * that lies with it restricts itself. `readOnlyHint` moves an action the other way, and it would + * buy nothing in the two curated cases — a name on `writeTools` never reaches the column, and a + * name absent from it already reads as a read — while opening the third: a server an + * administrator added by URL has no reviewed list, and `classifyTool` returns on the recorded + * column BEFORE its `if (!entry) return "write"`, so believing the hint would let an arbitrary + * server declare its whole surface harmless and turn "no reviewed list means everything is a + * write" into an opt-out. + * + * WHY NULL IS THE ASSERTION rather than a classification. Both `readOnlyHint` fixtures come out + * of `listServers` correctly whatever the column holds — one is on the write list, the other is + * not — so a classification assertion alone would pass with the hint written down and overruled + * downstream. NULL in the column is what says it was never read. + */ + test("a refresh records the effect an MCP server declares, and only the narrowing one", async () => { + await putClient(EVICTED); + await connect(); + accepted = new Set([EVICTED.clientId]); + + /** Suite-scoped, so it is not a name Notion really advertises nor one `writeTools` covers. */ + const destructiveName = `notion-destroy-${suite}`; + + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-fetch", + description: + "A read no write list names, declaring itself read-only.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + { + name: "notion-create-pages", + description: "A reviewed write, declaring itself read-only.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + { + name: destructiveName, + description: "Advertised, on no write list, declared destructive.", + inputSchema: { type: "object", properties: {} }, + annotations: { destructiveHint: true }, + }, + ], + async () => { + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 3 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + // The reviewed write, which said it was read-only. Nothing recorded, so nothing to + // overrule: the write list is still the only thing that answers for this name. + { name: "notion-create-pages", effect: null, destructive: false }, + // The declaration that narrows, taken at its word. + { name: destructiveName, effect: "write", destructive: true }, + // The unreviewed read, which also said it was read-only, and is believed about nothing. + { name: "notion-fetch", effect: null, destructive: false }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); + + // What the Plugins page derives, which is what an administrator actually reads: the + // reviewed name is a write because review says so, the declared one is a write because the + // vendor narrowed it, and the third is the read it was already classified as. + expect( + listed?.tools.map((tool) => ({ + name: tool.name, + effect: tool.effect, + })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: destructiveName, effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + }, + ); + }); + /** * What a failed refresh writes into `lastError`, and how much of it. * From 6192122f1f0960aad34865396d48a59b21c9a10f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:31:50 +0200 Subject: [PATCH 83/93] Send an unresolvable row's sentence to the operator and to nobody else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ServerRowAmbiguousError` refuses a row whose provenance says composio and whose id is a curated slug, and it was caught nowhere. The refresh route rethrew it into the default handler — a 500 with no JSON, which the admin page renders as "That did not work" — while `grantedTools` copied its message into a model's context, so a sentence naming a column of ours and telling the reader to correct it became a Bot's explanation to an end user. The one audience that could act on it saw nothing; the one that could not saw all of it. The distinction the codebase already draws by hand is now asked once, as `isDeploymentFault`: `PluginRefusedError` is the class relayed verbatim because the asker can act on it, and this is its opposite. The refresh route, which is admin-gated, answers 409 with the sentence in full. `grantedTools` answers "That tool could not be called." and nothing more. The tool-call route, which is not admin-gated, answers 500 with a neutral sentence instead of 502 and `failed: true`, which asserted a vendor had been reached. Landed with the second refusal on that shelf rather than after it, because the shelf is what makes either of them safe. A catalogue entry may no longer declare `transport: "composio"`: `CuratedTransportKind` makes it a compile error, and `accessFor` refuses it at resolution for when the type is bypassed. Unrefused it produced a Composio dial with `toolkit: null` and a credential taken from the entry's auth kind, so both gates that keep one person's brokered account out of another's were keyed on a null app and skipped, and the trail named whose account had been reached from a field unrelated to it. No entry declares it today, which is why this is a door being shut. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 69 ++++++- server/src/plugins/catalogue.ts | 8 +- server/src/plugins/routes.ts | 43 ++++ server/src/plugins/store.ts | 39 +++- server/src/plugins/tools.ts | 22 +- server/src/plugins/transport.ts | 19 ++ server/tests/plugin-routes.test.ts | 85 ++++++++ server/tests/plugin-store.integration.test.ts | 190 ++++++++++++++++++ 8 files changed, 470 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index d0fa2f2a1..825b5acad 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -101,6 +101,29 @@ const REACHED_AS_BY_AUTH: Record< builtin: "person", }; +/** + * The shelf both refusals below sit on: this deployment cannot say how to reach a row, and will + * not guess. + * + * CRITERION. Nothing on this shelf is a vendor's doing, a credential's doing, or anything the + * person asking can act on. An operator gets the sentence, because it names two of our own columns + * and what to do about them; every other audience — a model's context, a person's browser — gets + * the fact that the call did not happen, and none of the sentence. + * + * REASON. `store.ts` draws exactly this line already, between {@link PluginRefusedError} — a + * refusal somebody CAN act on, and the one class the codebase relays verbatim — and + * `PluginInvariantError`, a state this deployment's own code says cannot exist. These two are that + * second kind, found one step earlier: in resolving the row rather than in querying against it. + * + * A BASE CLASS RATHER THAN A LIST AT EACH AUDIENCE. `ServerRowAmbiguousError` shipped with no + * `catch` anywhere, so the refresh route rethrew it into the default handler — an administrator + * got a 500 with no body and a page that said "That did not work" — and `grantedTools` copied its + * message into a model's context, offering an end user's Bot a sentence about correcting a + * provenance column. A third contradiction added here has to be refused everywhere without a + * second edit, so the audiences ask one question: `isDeploymentFault` in `store.ts`. + */ +export abstract class ServerUnresolvableError extends Error {} + /** * A row that claims to be two servers at once, which makes it neither. * @@ -126,13 +149,51 @@ const REACHED_AS_BY_AUTH: Record< * rather than imported from `store.ts` because this module is a leaf — `store.ts` imports it, and * it imports nothing back. */ -export class ServerRowAmbiguousError extends Error { +export class ServerRowAmbiguousError extends ServerUnresolvableError { constructor(message: string) { super(message); this.name = "ServerRowAmbiguousError"; } } +/** + * A reviewed entry that names a transport no entry can be reached over. + * + * CRITERION. An entry declaring `transport: "composio"` is refused at resolution, and no answer is + * produced for it. + * + * REASON. {@link CuratedTransportKind} already keeps the value out of the catalogue at compile + * time, which is where it belongs — nothing writes an entry at runtime. This is what stands behind + * a cast, a JSON fixture in a test, or a future loader that reads entries from somewhere: what the + * unrefused answer WAS is a Composio dial with `toolkit: null` and a `reachedAs` copied from the + * entry's auth kind, so both store gates that keep one person's brokered account out of another's + * were skipped and the trail said the wrong thing about whose account was reached. Fail-closed + * costs one comparison; the alternative is a hole that opens the first time the type is bypassed. + */ +export class CatalogueTransportUnroutableError extends ServerUnresolvableError { + constructor(message: string) { + super(message); + this.name = "CatalogueTransportUnroutableError"; + } +} + +/** + * Whether a kind is the broker's, asked through a function so the question survives being answered. + * + * CRITERION. This comparison must stay live even though {@link CuratedTransportKind} makes it + * unreachable from the catalogue as the catalogue stands today. + * + * REASON. Written inline against `entry.transport`, the compiler narrows the operand to the three + * curated kinds and rejects the comparison as pointless — correctly, and only while nothing + * bypasses the type. A cast, a test fixture, or a loader that ever reads entries from outside the + * build would each produce the state this refuses, and each arrives at runtime where a type says + * nothing. Widening to {@link TransportKind} at a parameter costs one call and keeps both the + * compile-time door and the runtime one shut, rather than trading the second for the first. + */ +function isBrokerTransport(kind: TransportKind): boolean { + return kind === "composio"; +} + /** * A reviewed entry decides for itself; otherwise the row decides — and a row that claims both is * refused rather than resolved. @@ -163,6 +224,12 @@ export function accessFor( } if (entry) { + if (isBrokerTransport(entry.transport ?? "mcp")) { + throw new CatalogueTransportUnroutableError( + `${entry.key} is a catalogue entry declaring the composio transport, which is reached from a row's provenance and the app slug in its url — neither of which an entry has. There is no app to broker to and no connection to check, so this entry is not resolved at all: give it the transport it is actually reached over.`, + ); + } + return { transport: entry.transport ?? "mcp", credential: CREDENTIAL_BY_AUTH[entry.auth.kind], diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index c3a91fdbc..afd265821 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -30,7 +30,7 @@ // cloud credentials. `target.ts` imports nothing itself, so asking it here adds no dependency. import { isNeverAllowedHostname } from "../computer/target"; // Type-only, so naming the transport here creates no import cycle with the registry that resolves it. -import type { TransportKind } from "./transport"; +import type { CuratedTransportKind } from "./transport"; export type CatalogueAuth = /** Answers without any credential at all. */ @@ -132,8 +132,12 @@ export type CatalogueEntry = { * serves Drive over both an MCP endpoint and an ordinary REST API, and which one this deployment * uses is a decision about availability and risk rather than a property of the vendor. Naming it * here keeps that decision beside the host it applies to, and makes reversing it a one-line diff. + * + * NOT EVERY KIND, and the narrowing is the point: see {@link CuratedTransportKind}. The broker's + * transport is reached from a row's provenance and its url, never from an entry, and an entry + * naming it resolves to a Composio dial with no app and no brokered gate. */ - transport?: TransportKind; + transport?: CuratedTransportKind; docsUrl: string; }; diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 8fdc88535..639b72d69 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -19,6 +19,7 @@ import { CustomServerRefusedError, type OAuthClient, type PluginKind, + isDeploymentFault, PluginRefusedError, type PluginStore, } from "./store"; @@ -330,6 +331,26 @@ export function createPluginRoutes( if (error instanceof CatalogueEntryUnknownError) { return context.json({ error: error.message }, 404); } + /* + * The one audience the sentence was written for, and the only route that may show it. + * + * CRITERION. A contradiction between this deployment's own columns comes back to an + * administrator as itself: a body, naming the row and what to do about it. + * + * REASON. Unmapped, it reached the framework's default handler — a 500 with no JSON at all, + * which the admin page reads as "That did not work", the fallback it uses when a response + * carries no message. So the one refusal that names exactly which row is wrong and how to + * correct it was the one an operator could not see, while the same sentence WAS reaching a + * model on the tool-call path. This route is `requireAdmin`, which is what makes showing it + * here safe and showing it anywhere else not. + * + * 409 rather than 500: nothing broke, and nothing about the request was malformed. Two rows + * of ours disagree, and the request cannot be answered until one of them changes — which is + * what the sentence tells the reader to go and do. + */ + if (isDeploymentFault(error)) { + return context.json({ error: error.message }, 409); + } throw error; } }); @@ -879,6 +900,28 @@ export function createPluginRoutes( if (error instanceof CatalogueEntryUnknownError) { return context.json({ error: error.message }, 404); } + /* + * Ours, and so neither the vendor's fault nor this caller's business. + * + * CRITERION. A fault on the `isDeploymentFault` shelf is not reported through the branch + * below, and its sentence does not leave this process by this route. + * + * REASON. Two things would be wrong at once. `failed: true` and 502 say somebody else's + * software did not answer, which is a false statement about a call that never went out — + * and this route is `requireUser`, not `requireAdmin`, so the sentence naming our columns + * and the correction to make would be readable by anybody with a session. The operator who + * can act on it reads it on the refresh route above, which is admin-gated; here the honest + * answer is that the deployment cannot make this call as it stands. + */ + if (isDeploymentFault(error)) { + return context.json( + { + error: + "That tool is not configured in a way this deployment can act on. An administrator has to look at the server it belongs to.", + }, + 500, + ); + } // A server that failed is not a refusal, and saying so matters: one means the deployment // decided against it, the other means somebody else's software did not answer. return context.json( diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 2edabc0cd..42eb3477f 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -40,7 +40,11 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; -import { accessFor, type ServerAccess } from "./access"; +import { + accessFor, + type ServerAccess, + ServerUnresolvableError, +} from "./access"; import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; import { type ListedTool, McpServerError } from "./mcp"; @@ -250,6 +254,39 @@ export class PluginInvariantError extends Error { } } +/** + * Whether a throw is this deployment contradicting itself, rather than anything anybody asked for. + * + * CRITERION. Every audience boundary asks THIS instead of listing classes of its own. A fault it + * answers true for reaches an operator as its own sentence, on a surface only an operator can + * reach, and reaches everybody else as the fact that the call did not happen — no message, no + * column names, no instruction about a row. + * + * REASON. The distinction already existed and was drawn by hand, once, in each place that + * remembered to draw it: {@link PluginRefusedError} is relayed verbatim because it is a refusal + * the asker can act on, and everything else fell into a branch that copies `error.message` + * onwards. {@link ServerUnresolvableError} was caught by none of them — the refresh route rethrew + * it into the framework's default handler, which answers a bodiless 500, so the admin page said + * "That did not work" and named nothing; `grantedTools` put its message in a model's context, + * where a sentence telling an operator to correct a provenance column became a Bot's explanation + * to an end user of why their tool failed. Two audiences, one refusal, neither served. + * + * {@link PluginInvariantError} is on the same shelf and answers true for the same reason: it is + * this deployment finding a state its own code says cannot exist. That is not a vendor + * misbehaving and not a person's to act on mid-call, and its own docblock has said so since it + * was written — what it lacked was anywhere that asked. + * + * A PREDICATE RATHER THAN A SHARED BASE CLASS, because the two live in different modules and must + * keep doing so: `access.ts` is a leaf that `store.ts` imports, so the shelf cannot be declared + * once without one of them importing the other back. + */ +export function isDeploymentFault(error: unknown): error is Error { + return ( + error instanceof ServerUnresolvableError || + error instanceof PluginInvariantError + ); +} + /** * The vendor's `error` code, when a token endpoint refuses an exchange. * diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 4e54bf20c..905a815fa 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import type { AuditInitiator } from "../audit"; import type { SelectableSkill } from "./selection"; -import { PluginRefusedError, type PluginStore } from "./store"; +import { + isDeploymentFault, + PluginRefusedError, + type PluginStore, +} from "./store"; /** * The tools a Bot may call, as the runtime's own tool definitions, executed on the server. @@ -203,6 +207,22 @@ export async function grantedTools(options: { if (error instanceof PluginRefusedError) { return `${REFUSAL_MARKER} ${error.message}`; } + /* + * A contradiction in this deployment's own tables says nothing to a model. + * + * CRITERION. Nothing on the `isDeploymentFault` shelf may have its message relayed from + * here, whatever it says. + * + * REASON. The branch below hands `error.message` to the model, which is right for a + * vendor's own words — that is somebody else's software explaining itself, and the + * diagnosis is worth having. These are not that. `ServerRowAmbiguousError` names two of + * our columns and tells the reader to rename a row or correct its provenance: an + * instruction only an operator can carry out, arriving in an end user's model context as + * the reason their tool failed, from which the model can only invent something to tell + * them. The operator who can act on it is served on the admin surface instead, where the + * refresh route now answers with the sentence in full. + */ + if (isDeploymentFault(error)) return "That tool could not be called."; // A vendor that failed is not a refusal, and the difference matters to the person reading // the answer: one means "not allowed", the other means "it broke". return error instanceof Error diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index cead6af40..cf225df8c 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -113,6 +113,25 @@ export type TransportKind = | "builtin-routines" | "composio"; +/** + * The kinds a CATALOGUE ENTRY may name, which is every one except the broker's. + * + * CRITERION. `composio` is not writable in a reviewed entry, and the compiler is what says so. + * + * REASON. A brokered row is reached by an app slug read off its url and a per-person connection + * looked up by that slug; a catalogue entry has neither, and `accessFor` answers `toolkit: null` + * and `credential` from the entry's auth kind for everything it resolves. So an entry declaring + * `transport: "composio"` yielded a Composio dial with no app named, no brokered gate, and + * `reachedAs` taken from an auth kind that has nothing to do with whose account the broker would + * have run in — a row that walks past both store gates while satisfying every type in the module + * that claims to enumerate how a row can be reached. No entry declares it, which is why this is a + * door being shut rather than a bug being fixed, and why shutting it costs nothing. + * + * `Exclude` rather than a hand-written second union, so a kind added above is offered to the + * catalogue automatically and only the broker stays out. + */ +export type CuratedTransportKind = Exclude; + const TRANSPORTS: Record = { mcp, "google-drive-rest": driveRest, diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 14c072769..205928e5f 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; +import { ServerRowAmbiguousError } from "../src/plugins/access"; import { CatalogueEntryUnknownError, CustomServerRefusedError, @@ -104,6 +105,90 @@ describe("adding a curated server", () => { }); }); +/** + * What a refresh that cannot be resolved at all looks like to the administrator who pressed it. + * + * CRITERION. A contradiction between two of this deployment's own columns comes back with a body + * that names the row and says what to correct, on this route and only on this route. + * + * REASON. `ServerRowAmbiguousError` was mapped nowhere, so it left the route on the framework's + * default path: a 500 whose body is not JSON, which the admin client turns into its fallback + * sentence — "That did not work" — having found no `error` field to read. The one refusal that + * names exactly which row is wrong was the one an operator could not see, while the same sentence + * was reaching a model on the tool-call path. This route is admin-gated, which is what makes + * showing it here the right answer and showing it anywhere else the wrong one. + */ +function refreshApp( + refreshTools: () => Promise, + role: "admin" | "user" = "admin", +) { + const store = { + refreshTools, + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => [], + listSkills: async () => [], + listGrants: async () => [], + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + { rolesForUser: async () => [role] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + ); + + return () => + app.request("http://openbot.test/api/plugins/servers/notion/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); +} + +describe("refreshing a server that cannot be resolved", () => { + test("the administrator is told which row and what to do about it", async () => { + const sentence = + "notion is a server this deployment ships an entry for, and a row with that id says its " + + "provenance is composio. Rename it, or correct its provenance."; + const request = refreshApp(async () => { + throw new ServerRowAmbiguousError(sentence); + }); + + const response = await request(); + + // 409 rather than 500: nothing broke and nothing about the request was malformed. Two rows + // disagree, and the request cannot be answered until one of them changes. + expect(response.status).toBe(409); + // A body at all is the fix. Unmapped, this was a 500 carrying no JSON, and the page said + // "That did not work" because that is what it says when it finds no message. + expect(await response.json()).toEqual({ error: sentence }); + }); + + test("a failure that is not one of ours is still not dressed up as one", async () => { + // The must-not case, the same one the add route above carries: a database that is down is not + // a row an administrator can go and correct, and answering 409 would send them to do it. + const request = refreshApp(async () => { + throw new Error("the database is unreachable"); + }); + + expect((await request()).status).toBe(500); + }); + + test("somebody who is not an administrator cannot press it at all", async () => { + const request = refreshApp(async () => { + throw new Error("the store must not be reached"); + }, "user"); + + // Which is what makes showing the sentence above safe: nobody else reaches this route. + expect((await request()).status).toBe(403); + }); +}); + /** * Granting one Bot to another, through the API an administrator actually has. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 4c01199d2..9f3c6aa98 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -32,12 +32,19 @@ import { pluginGrants, users, } from "../src/db/schema"; +import { + accessFor, + CatalogueTransportUnroutableError, + ServerRowAmbiguousError, +} from "../src/plugins/access"; +import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry } from "../src/plugins/catalogue"; import { type ComposioResult, useComposioClient, } from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { type AccessToken, CustomServerRefusedError, @@ -48,6 +55,7 @@ import { PluginInvariantError, PluginRefusedError, type PluginStore, + isDeploymentFault, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; @@ -5011,6 +5019,188 @@ describe("a refresh the vendor answered with no actions at all", () => { }); }); +/** + * WHO IS TOLD WHAT, when the row itself is the thing that cannot be resolved. + * + * `ServerRowAmbiguousError` refuses a row whose provenance says `composio` and whose id is a + * curated catalogue slug: nothing in the row and the entry tells a tampered curated row apart from + * a brokered app that took the name, so there is no answer that is not wrong in one of the two + * worlds. It shipped caught NOWHERE. Every audience therefore got the wrong thing at once — the + * admin page a bodiless 500 it renders as "That did not work", and a model the operator's own + * sentence about correcting a provenance column, offered to an end user as the reason their tool + * failed. + * + * The store's half is asserted here: it refuses, and it records nothing about a vendor while doing + * so. What each audience then sees is asserted where that audience is — the model below, and the + * administrator in `plugin-routes.test.ts`, which is the file that exists for that mapping. + */ +describe("a row that resolves to two servers at once", () => { + /** A colliding row, spelled the way the collision actually occurs: a curated slug, brokered. */ + async function seedCollidingNotion(database: Database) { + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Composio", + url: "composio://notion", + provenance: "composio", + }); + } + + test("a refresh refuses it, and writes nothing about a vendor", async () => { + const { store, database } = await freshStore(); + await seedCollidingNotion(database); + + await expect(store.refreshTools("notion", "admin_user")).rejects.toThrow( + ServerRowAmbiguousError, + ); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "notion")); + + // Two of our columns disagreeing is not a vendor's answer, so it must not be written where the + // page draws what the vendor said. Raised instead, which is what the route reads. + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("the model is told the call did not happen, and nothing about our columns", async () => { + const { store, database } = await freshStore(); + await seedCollidingNotion(database); + await database.insert(mcpTools).values({ + serverId: "notion", + name: "notion-fetch", + description: "Fetch a page.", + }); + await database.insert(agents).values({ + id: "bot_helper", + name: "Helper", + type: "built_in", + configuration: {}, + }); + await store.grant( + "mcp", + "notion/notion-fetch", + "bot_helper", + "admin@example.com", + ); + + const [tool] = await grantedTools({ + store, + botId: "bot_helper", + actorId: "user_asker", + }); + if (!tool) throw new Error("the Bot was offered no tool to call"); + + const answer = await tool.execute({}); + + /* + * Every part of the operator's sentence, named rather than summarised. + * + * The message says the row is one the deployment ships an entry for, that its provenance says + * composio, and that somebody should rename it or correct the column. Each of those is a fact + * about our database and an instruction only an administrator can act on; a model handed any + * of them can only relay or embroider it. Asserted piecewise so a reworded sentence that still + * leaks cannot pass by not matching one long string. + */ + expect(answer).not.toContain("provenance"); + expect(answer).not.toContain("rename"); + expect(answer).not.toContain("notion"); + // And not dressed as a refusal either: nothing was decided against, so the marker the + // transcript draws as a boundary holding would be a lie about which of the two happened. + expect(answer.startsWith(REFUSAL_MARKER)).toBe(false); + expect(answer).toBe("That tool could not be called."); + }); + + test("it is on the same shelf the store already raises rather than records", async () => { + /* + * The distinction, asked the way every audience asks it. + * + * Both audiences above branch on `isDeploymentFault` rather than on a class list of their own, + * so what makes them correct is this answer and not the two `catch` blocks. A class added to + * the shelf and forgotten here is the defect being fixed, one round later. + */ + expect(isDeploymentFault(new ServerRowAmbiguousError("x"))).toBe(true); + expect(isDeploymentFault(new CatalogueTransportUnroutableError("x"))).toBe( + true, + ); + expect(isDeploymentFault(new PluginInvariantError("x"))).toBe(true); + // And the refusal somebody CAN act on is not on it: its message is the one thing this codebase + // relays verbatim, to a model and to a browser alike. + expect(isDeploymentFault(new PluginRefusedError("x", null))).toBe(false); + expect(isDeploymentFault(new Error("the vendor did not answer"))).toBe( + false, + ); + }); +}); + +/** + * A catalogue entry naming the broker's transport, which no entry can be reached over. + * + * NOT A LIVE BUG AND NOT MEANT TO BECOME ONE. No entry declares it, and `CuratedTransportKind` now + * makes declaring it a compile error — which is the real fix, since entries are code. This is what + * holds when the type is bypassed: a cast, a fixture like the one below, or a loader that ever + * reads an entry from outside the build. + * + * WHAT THE UNREFUSED ANSWER WAS. `transport: "composio"` with `toolkit: null`, a credential taken + * from the entry's auth kind rather than `brokered`, and `reachedAs` from the same table. So the + * dial went to the broker while both gates that keep one person's brokered account out of + * another's — the connection lookup in `connectionTokenFor` and the app-slug check in + * `refreshTools` — were keyed on a null app and skipped, and the trail recorded whose account had + * been reached from a field that had nothing to do with it. Refusing is the only answer that does + * not assert something false. + */ +test("a catalogue entry declaring the broker's transport is refused, not dialled", () => { + /* + * Cast at the fixture, deliberately and in one place. The type is what keeps this out of the + * catalogue, so a test about what happens when the type is bypassed has to bypass it — and doing + * it here rather than in a helper keeps the bypass visible beside the thing it is testing. + */ + const brokered = { + key: "brokered-entry", + title: "Brokered Entry", + vendor: "Somebody", + summary: "An entry that names a transport an entry cannot be reached over.", + host: "https://mcp.example.com", + path: "/mcp", + auth: { + kind: "user-oauth" as const, + authorizationUrl: "https://example.com/auth", + tokenUrl: "https://example.com/token", + revokeUrl: "https://example.com/revoke", + scopes: [], + }, + writeTools: [], + transport: "composio", + docsUrl: "https://example.com/docs", + } as unknown as CatalogueEntry; + + expect(() => + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + brokered, + ), + ).toThrow(CatalogueTransportUnroutableError); + + // The same entry with the transport it is actually reached over resolves as any other curated + // per-person vendor does, so what is refused is the value and not the fixture. + expect( + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + { ...brokered, transport: undefined }, + ), + ).toEqual({ + transport: "mcp", + credential: "person-oauth", + reachedAs: "person", + toolkit: null, + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From f892e6147cf2c17ec99509f35f5d55b0aaa43f71 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:39:21 +0200 Subject: [PATCH 84/93] Keep the statement and its parameters out of a failed replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vendor naming one action twice hits `mcp_tools`' `(server_id, name)` primary key, and a U+0000 anywhere in what a vendor wrote is a byte PostgreSQL will not encode. Both aborted the wholesale replace from inside its transaction, which sits outside the vendor `try` above it, so what left `refreshTools` was drizzle's `DrizzleQueryError` — `Failed query:` plus the whole insert, then `params:` and every value bound to it. That is the disclosure shape of a credential leak one layer out, and it went to the logs and to any caller that prints an error while `lastError` kept whatever it held before. Neither reaches a statement now. `storableTools` drops a name the vendor listed twice, keeping the first occurrence because nothing says which of two identical names is real, and strips U+0000 from the name, the description, the version and the schema. What is left is a transaction failing for reasons that are genuinely not the vendor's — which is what the comment on the replace has always claimed — and that is raised as a `PluginInvariantError` carrying the driver's own complaint and none of the query, through `databaseComplaint`. The count returned and the set the stranded-grant audit compares against are both taken from what was stored rather than from what was listed, so a duplicate is one action in both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 175 +++++++++++++--- server/tests/plugin-store.integration.test.ts | 195 ++++++++++++++++++ 2 files changed, 342 insertions(+), 28 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 42eb3477f..ceca63e71 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -280,6 +280,109 @@ export class PluginInvariantError extends Error { * keep doing so: `access.ts` is a leaf that `store.ts` imports, so the shelf cannot be declared * once without one of them importing the other back. */ +/** + * The one character no PostgreSQL `text` or `jsonb` value can hold, whatever the vendor sent. + * + * Not a length limit and not an encoding preference: the server rejects the statement outright, + * mid-transaction, and the rejection arrives as a query error rather than as anything about the + * value. + */ +const NUL = "\u0000"; + +/** + * The driver's own complaint about a query, without the query. + * + * CRITERION. What this returns never contains the statement or the values bound to it. + * + * REASON. drizzle's `DrizzleQueryError` puts both in its own `message` and hangs the driver's + * error off `cause`. The driver's message is the useful half — `duplicate key value violates + * unique constraint`, `invalid byte sequence`, `canceling statement due to statement timeout` — + * and it is the half that names nothing anybody sent. An error shaped differently gets a fixed + * sentence rather than its own message, because the reason this exists is that a message from an + * unexamined shape is exactly what leaked the last one. + * + * Capped where every other quoted failure in this file is capped, for the same reason: parts of + * it come from somewhere else and none of it is a promise about length. + */ +function databaseComplaint(error: unknown): string { + const cause = error instanceof Error ? error.cause : undefined; + return cause instanceof Error + ? cause.message.slice(0, 400) + : "The database gave no reason this deployment can quote."; +} + +/** + * What a vendor listed, as rows this database will actually take. + * + * CRITERION ONE. No two rows carry the same name, whatever the vendor listed. + * + * CRITERION TWO. No string reaching the insert contains U+0000, in a column or inside a schema. + * + * REASON. Both of these used to abort the replace from INSIDE the transaction and OUTSIDE the + * vendor `try` above it, so they came out of `refreshTools` as a raw `DrizzleQueryError` — whose + * message is `Failed query: ` followed by `params:` and every value bound to + * it. That reached an operator's page and the logs as a SQL dump, which is the same disclosure + * shape as a leaked credential one layer out, and it left `lastError` holding whatever was there + * before: stale, or null, on a refresh that had in fact failed. + * + * FIXED BY NOT REACHING THE DATABASE WITH IT, rather than by catching it better. A vendor that + * names one action twice is answering about one action — `mcp_tools`' `(server_id, name)` primary + * key says so, and the first listing is as good an answer as the second, so the duplicate is + * dropped rather than made into an error somebody has to act on. A control character in a + * description is not content anybody wants to keep either. What is left after this is a + * transaction that fails for reasons that are genuinely not the vendor's, which is what the + * comment on the replace has always claimed. + * + * FIRST OCCURRENCE WINS, and the order is the vendor's own. Anything else needs a rule for which + * of two identical names is the real one, and there is no such rule. + */ +function storableTools(serverId: string, listed: ListedTool[]) { + const byName = new Map< + string, + { + serverId: string; + name: string; + description: string; + inputSchema: Record; + effect: "read" | "write" | null; + destructive: boolean; + version: string | null; + } + >(); + + for (const tool of listed) { + const name = tool.name.replaceAll(NUL, ""); + if (byName.has(name)) continue; + byName.set(name, { + serverId, + name, + description: tool.description.replaceAll(NUL, ""), + /* + * Through JSON rather than by walking the object, because the escape is what has to go and + * the schema is JSON by definition — it is stored in a `jsonb` column and came off the wire + * as JSON. `JSON.stringify` writes a literal U+0000 as the six characters `\u0000`, so that + * is the sequence removed here; a schema with none is rebuilt identical. + */ + inputSchema: JSON.parse( + JSON.stringify(tool.inputSchema).replaceAll("\\u0000", ""), + ), + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes an effect and a version, and an MCP server publishes a + * destructive hint — see `mcp.ts`. All three stay null or false for a transport that says + * nothing, and `classifyTool` reads null as silence rather than as a value, which is what + * leaves Notion and Drive classified by their reviewed write list exactly as they were. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version?.replaceAll(NUL, "") ?? null, + }); + } + + return [...byName.values()]; +} + export function isDeploymentFault(error: unknown): error is Error { return ( error instanceof ServerUnresolvableError || @@ -2482,32 +2585,43 @@ export function createPluginStore(options: PluginStoreOptions) { * rather than being copied into `lastError`, because a transaction this database would not take * is not something the vendor did. */ - await database.transaction(async (transaction) => { - await transaction - .delete(mcpTools) - .where(eq(mcpTools.serverId, serverId)); - if (listed.length > 0) { - await transaction.insert(mcpTools).values( - listed.map((tool) => ({ - serverId, - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - /* - * What the vendor said, when the vendor said anything. - * - * Only Composio publishes any of this today, so all three stay null or false for - * every other transport — and `classifyTool` reads null as silence rather than as a - * value, which is what leaves Notion and Drive classified by their reviewed write list - * exactly as they were before. - */ - effect: tool.effect ?? null, - destructive: tool.destructive ?? false, - version: tool.version ?? null, - })), - ); - } - }); + // Names deduplicated and vendor text made storable before a transaction is opened on any of + // it, because both failures used to abort the replace from inside one. See + // {@link storableTools}. + const storable = storableTools(serverId, listed); + + try { + await database.transaction(async (transaction) => { + await transaction + .delete(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + if (storable.length > 0) { + await transaction.insert(mcpTools).values(storable); + } + }); + } catch (error) { + /* + * A database failure, with the statement and its parameters left behind. + * + * CRITERION. Nothing raised from here carries the SQL or the values bound to it. + * + * REASON. drizzle wraps every failure as a `DrizzleQueryError`, whose message is + * `Failed query:` followed by the whole statement and then every parameter — here, the + * vendor's entire tool list. That message is what an unhandled throw puts in the logs and + * what any caller that prints an error puts on a screen. A SQL dump on an error path is + * the same disclosure shape as a credential leak one layer out, and it is gratuitous: the + * driver's own complaint says what went wrong without any of it. + * + * RAISED, NOT RECORDED, which is what the paragraph above this transaction argues for and + * is now true rather than merely intended: with duplicate names and unstorable text + * removed before the statement is built, what is left is this database refusing something + * this deployment's own schema says it will take, and `lastError` is where a VENDOR's + * answer goes. + */ + throw new PluginInvariantError( + `${row.id}: the actions this app listed were not stored, so what it already had is unchanged. ${databaseComplaint(error)}`, + ); + } await database .update(mcpServers) @@ -2533,7 +2647,9 @@ export function createPluginStore(options: PluginStoreOptions) { * committed, because the guard above returns before this on an empty answer that would have * named every grant the app holds. */ - const advertised = new Set(listed.map((tool) => tool.name)); + // The names as STORED, so a grant is compared against a row that exists: a duplicate the + // vendor listed twice is one row, and a name is spelled here the way the insert spelled it. + const advertised = new Set(storable.map((tool) => tool.name)); const stranded = [...(await mcpGrantsForServers([serverId])).entries()] .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) .sort(([left], [right]) => left.localeCompare(right)); @@ -2583,7 +2699,10 @@ export function createPluginStore(options: PluginStoreOptions) { }); } - return { tools: listed.length }; + // What was recorded, which is what "this app offers N actions" means on the page. Counting + // the listing instead reported a duplicate the vendor named twice as two actions the + // deployment holds, when `mcp_tools` holds one row for it. + return { tools: storable.length }; }, async listServers(): Promise { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 9f3c6aa98..5ed224acc 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -5201,6 +5201,201 @@ test("a catalogue entry declaring the broker's transport is refused, not dialled }); }); +/** + * WHAT A VENDOR SENT THAT THIS DATABASE WILL NOT TAKE, and what came out when it did not. + * + * Both of these aborted the wholesale replace from INSIDE its transaction, which sits OUTSIDE the + * vendor `try` above it — so neither was recorded and neither was caught. What left `refreshTools` + * was drizzle's `DrizzleQueryError`, whose message is `Failed query:` followed by the entire + * statement and then `params:` and every value bound to it. A SQL dump on an error path is the + * same disclosure shape as a credential leak one layer out, and it reached the logs and any caller + * that prints an error, while `lastError` sat holding whatever it held before: stale, or null, on + * a refresh that had failed outright. + * + * Both are now settled before a statement is built, which is why the assertions below are about + * the rows rather than about a better error. + */ +describe("a listing this database would not have taken", () => { + test("a vendor naming one action twice records it once", async () => { + const { store, database } = await freshStore(); + /* + * The same slug twice, with different text, which is what a paginated listing that overlaps + * or a broker with two entries for one action produces. `(server_id, name)` is the primary + * key, so as one multi-row insert this refused the whole statement and rolled the delete back + * with it — leaving the app holding its old actions and the refresh throwing a dump. + */ + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email, listed again.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + // One action, because the vendor named one action. Not two rows, and not a refusal. + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const rows = await database + .select({ + name: mcpTools.name, + description: mcpTools.description, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The first occurrence, because the order is the vendor's own and there is no rule that says + // which of two identical names is the real one. + expect(rows).toEqual([ + { name: "GMAIL_SEND_EMAIL", description: "Send an email." }, + ]); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + // A healthy refresh, because that is what it was. + expect(row?.lastError).toBeNull(); + }); + + test("a U+0000 in what the vendor wrote is dropped rather than aborting the replace", async () => { + const { store, database } = await freshStore(); + /* + * In the name, in the description and inside the schema, because all three reach the insert + * and the column types differ: `text` refuses the byte and `jsonb` refuses the escape, and + * each aborts the same transaction from a different statement position. + */ + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND\u0000_EMAIL", + description: "Send\u0000 an email.", + inputParameters: { + type: "object", + properties: { subject: { description: "The\u0000 subject" } }, + }, + version: "2026\u00000903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const [stored] = await database + .select({ + name: mcpTools.name, + description: mcpTools.description, + inputSchema: mcpTools.inputSchema, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + expect(stored?.name).toBe("GMAIL_SEND_EMAIL"); + expect(stored?.description).toBe("Send an email."); + expect(stored?.version).toBe("20260903_00"); + // Inside the schema too, and the rest of the schema rebuilt exactly as it arrived. + expect(stored?.inputSchema).toEqual({ + type: "object", + properties: { subject: { description: "The subject" } }, + }); + }); + + test("a replace this database still refuses raises without the statement", async () => { + /* + * A transaction forced to fail, because after the two cases above nothing a vendor can send + * reaches this branch — and this branch is the one that used to publish the dump. What is + * asserted is the SHAPE of what comes out: the driver's own complaint, and none of the + * statement or the values bound to it. + */ + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + /* + * Derived from the real one rather than stubbed, so every other query the refresh makes is + * the real query. The failure is spelled the way drizzle spells one: the statement and every + * bound value in `message`, the driver's own error hung off `cause`. That message is what + * used to escape. + */ + const refusing: Database = Object.create(database); + Object.defineProperty(refusing, "transaction", { + value: async () => { + throw Object.assign( + new Error( + 'Failed query: insert into "mcp_tools" ("server_id", "name") values ($1, $2) params: gmail, GMAIL_SEND_EMAIL', + ), + { + cause: new Error( + 'duplicate key value violates unique constraint "mcp_tools_pkey"', + ), + }, + ); + }, + }); + + const failing = createPluginStore({ + database: refusing, + auditStore: { insert: async () => {} }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + let thrown: unknown; + try { + await failing.refreshTools("gmail", "admin_user"); + } catch (error) { + thrown = error; + } + + const message = thrown instanceof Error ? thrown.message : String(thrown); + // The driver's complaint, which names what went wrong. + expect(message).toContain("duplicate key value violates unique constraint"); + // And nothing of the statement or of what was bound to it. + expect(message).not.toContain("Failed query"); + expect(message).not.toContain("insert into"); + expect(message).not.toContain("params:"); + // On the shelf that is raised rather than recorded and never relayed to a model, because a + // transaction this database would not take is not something the vendor did. + expect(isDeploymentFault(thrown)).toBe(true); + + // And what the app already had is still there, because nothing was committed. + expect( + await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")), + ).toEqual([{ name: "GMAIL_FETCH_EMAILS" }]); + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From 0f187d99771cbafa2ab1c77e63e1396a4103d951 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:41:12 +0200 Subject: [PATCH 85/93] File a brokered disconnect under the app, whichever act ended it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcp.account_disconnected` is written by the two acts that can end a brokered connection, and they keyed it differently: offboarding on the connection's toolkit, removing the app on `mcp_servers.id`. Nothing holds those two strings equal — the id is a display key and the slug in the url is what the broker is asked about — so on any renamed row half the trail is filed under a name the other half never mentions and no single query answers what happened to one person's brokered access. Both were written by recent waves, and every fixture in this suite spelled the two the same, which is why it went unseen. The app is the key, because the app is what was consented to: the gate is `(toolkit, user_id)`, the delete is by toolkit, and the row outlives the server row entirely, so the id may not exist by the time anybody asks. Which server row was removed is still recorded — the `configuration.changed` row written in the same call names it. ALSO CARRIES ONE CHANGE THAT BELONGS TO THE COMMIT BEFORE IT, in the same file and named here rather than left to be found: `storableTools` reads `.replaceAll` off a description and stringifies a schema, where the mapping it replaced passed both straight through — so a transport handing back undefined used to get the `""` and `{}` those columns default to, and would now get a TypeError thrown from outside the vendor `try`. Both fields are required by `McpTool` and supplied by every transport here, so this restores the tolerance the insert already had rather than adding an answer of its own. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 35 +++++++-- server/tests/composio-connections.test.ts | 87 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index ceca63e71..353c6eae8 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -356,7 +356,15 @@ function storableTools(serverId: string, listed: ListedTool[]) { byName.set(name, { serverId, name, - description: tool.description.replaceAll(NUL, ""), + /* + * Defaulted where the COLUMN has a default, because that is what the previous mapping leaned + * on: it passed these two straight through, so a transport handing back undefined got the + * `""` and `{}` the schema declares. Reading a method off the value instead would turn the + * same absence into a TypeError thrown from outside the vendor `try`. Both fields are + * required by `McpTool` and supplied by every transport here; this keeps the tolerance the + * insert already had rather than adding a new answer. + */ + description: (tool.description ?? "").replaceAll(NUL, ""), /* * Through JSON rather than by walking the object, because the escape is what has to go and * the schema is JSON by definition — it is stored in a `jsonb` column and came off the wire @@ -364,7 +372,7 @@ function storableTools(serverId: string, listed: ListedTool[]) { * is the sequence removed here; a schema with none is rebuilt identical. */ inputSchema: JSON.parse( - JSON.stringify(tool.inputSchema).replaceAll("\\u0000", ""), + JSON.stringify(tool.inputSchema ?? {}).replaceAll("\\u0000", ""), ), /* * What the vendor said, when the vendor said anything. @@ -2321,10 +2329,29 @@ export function createPluginStore(options: PluginStoreOptions) { await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", - targetId: serverId, + /* + * THE APP, not this row's id, and the same key `retireConnectionsFor` files under. + * + * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces is + * keyed on the app at the broker, whichever act produced it, so one query answers + * what happened to one person's brokered access. + * + * REASON. The two acts that can end such a connection were keyed differently: this + * one on `mcp_servers.id`, offboarding on `composio_connections.toolkit` — which is + * all that row records and all that is left once the server row is gone. Nothing + * holds the two strings equal, so on any renamed row half the trail is filed under a + * name the other half never mentions, and the disagreement is invisible everywhere + * they happen to match. + * + * THE APP IS WHAT WAS CONSENTED TO. The gate is `(toolkit, user_id)`, the delete + * above is by toolkit, and the row outlives the server row entirely; the id is a + * display key that may not exist by the time somebody asks. Which server row was + * removed is not lost — the `configuration.changed` row written below names it. + */ + targetId: toolkit, payload: { actor: by, - server: serverId, + server: toolkit, owner: connection.userId, // The same three-way distinction the vault loop above draws, and the same answer: an // administrator took the whole app away and the person did nothing. diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 33c274dbb..ad7772588 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -62,6 +62,14 @@ const botId = `agent_revoke_${suite}`; const askerId = `user_asker_${suite}`; /** Somebody who connected it and whose `users` row is then deleted out from under the connection. */ const leaverId = `user_leaver_${suite}`; +/** + * The same app under a display id that is NOT its slug, which is a legal row and an ordinary one. + * + * `mcp_servers.id` is what an operator sees and what a grant is written against; the slug in the + * url is what the broker is asked about. Nothing holds the two equal, and every fixture above + * spells them the same — which is exactly why a defect that only shows when they differ survived. + */ +const renamedId = `renamed-${suite}`; const admin = "admin@openbot.local"; const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; @@ -155,8 +163,12 @@ function useAnsweringClient(actions: Partial = {}) { async function clean() { await database.delete(pluginGrants).where(eq(pluginGrants.agentId, botId)); await database.delete(agents).where(eq(agents.id, botId)); - await database.delete(mcpTools).where(eq(mcpTools.serverId, toolkit)); - await database.delete(mcpServers).where(eq(mcpServers.id, toolkit)); + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, [toolkit, renamedId])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, [toolkit, renamedId])); await database .delete(composioConnections) .where(eq(composioConnections.toolkit, toolkit)); @@ -385,6 +397,77 @@ test("removing the app takes every brokered connection to it", async () => { }); }); +/** + * ONE KEY FOR "WHAT HAPPENED TO THIS PERSON'S ACCESS", across both acts that can end it. + * + * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces names the APP at + * the broker — in `targetId` and in `payload.server` — whichever act produced it. + * + * REASON. The two acts were written in different waves and keyed differently. Offboarding files + * under `connection.toolkit`, which is all a connection row records and all that is left once the + * server row is gone. Removing the app filed under the `mcp_servers` id. Where the two spellings + * agree — which they do in every other fixture in this file, and in the product whenever nobody + * renamed anything — the disagreement is invisible; where they differ, no single query answers + * what happened to one person's access, because half the rows are filed under a name the other + * half never mentions. + * + * THE APP IS THE RIGHT KEY, not the row id. A brokered connection is consent to an app: the gate + * is `(toolkit, user_id)`, `removeServer` clears it by toolkit, and the row outlives the + * `mcp_servers` row entirely — so the id is not always available and is never what was consented + * to. Which server row was removed is not lost either: the `configuration.changed` row written in + * the same call names it. + */ +test("both acts that end a brokered connection file it under the app", async () => { + await database.insert(agents).values({ + id: botId, + name: "Helper", + type: "built_in", + configuration: {}, + }); + // The row id and the app slug deliberately different, which is the only shape that can tell the + // two keys apart. + await database.insert(mcpServers).values({ + id: renamedId, + title: "Revocable App", + vendor: "Composio", + url: `composio://${toolkit}`, + provenance: "composio", + }); + await database.insert(composioConnections).values([ + { toolkit, userId: askerId }, + { toolkit, userId: leaverId }, + ]); + + // Offboarding one person, then removing the app out from under the other. + expect((await store.retireConnectionsFor(leaverId, admin)).retired).toBe(1); + await store.removeServer(renamedId, admin); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + // Both rows, under one key. Asked as the set of keys rather than row by row, because what the + // criterion is about is a query finding all of them at once. + expect(new Set(disconnected.map((event) => event.targetId))).toEqual( + new Set([toolkit]), + ); + expect( + new Set( + disconnected.map((event) => (event.payload as { server: string }).server), + ), + ).toEqual(new Set([toolkit])); + + // And each still says which person and which of the three things happened to them, which is the + // other half of the question and was never the part that was wrong. + expect( + disconnected + .map((event) => event.payload as { owner: string; reason: string }) + .map(({ owner, reason }) => ({ owner, reason })) + .sort((left, right) => left.owner.localeCompare(right.owner)), + ).toEqual([ + { owner: askerId, reason: "mcp_server_removed" }, + { owner: leaverId, reason: "person_removed" }, + ]); +}); + /** * CONSENT MUST NOT REATTACH. * From 3ae8161fa6c450e1576543acc357373288acbb36 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:54:53 +0200 Subject: [PATCH 86/93] Ask one question about which connection rows this file owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refuse-to-run guard, the per-test sweep and the teardown each decided for themselves what makes a `composio_connections` row this file's, and all three answered differently. Two asked by PERSON across every app; one test's cleanup asked by the ANONYMOUS ACTOR across every app; the pair `("gmail", "")` was in none of them. So the file refused to run over rows it does not create, deleted rows it does not create, and left behind one that it does. Both halves of the key, once, as `ownedConnections`: every connection row this file writes is at `gmail`, for the two people it invents and the anonymous actor alike. A `("slack", "user_asker")` row is now neither refused over nor swept — and a `composio_connections` row is the entire gate on a brokered call, referenced by nothing, so deleting one is not recoverable by any operation the product has. One test did genuinely depend on owning `user_asker` at every app: it asserted a refusal because the row's url dialled `slack` and nobody had connected `slack`, which a real deployment row would have turned into a completed call reading as this gate being broken. The app it dials is now suite-scoped, which is what the property actually needs. The `user_leaver` select beside it is narrowed to the pair for the same reason. The witness row at `sweep_witness_${suite}`, which this file added and only its own `finally` removed, joins the teardown by its exact suffixed name: nothing else could reach it — the sweep is keyed on `gmail`, and the anonymous actor is what `retireConnectionsFor` refuses to act on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 149 +++++++++++------- 1 file changed, 96 insertions(+), 53 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 5ed224acc..b702749df 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -10,7 +10,7 @@ import { import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; -import { and, asc, eq, gte, inArray, like, or, sql } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -252,6 +252,26 @@ let ownsFixtureIds = false; * So the deletes in {@link freshDatabase} are authorised by {@link ownsFixtureIds} and this is what * makes them safe. */ +/** + * Every `composio_connections` row this file may claim, as one clause used by all three sites. + * + * CRITERION. The refuse-to-run guard, the per-test sweep and the teardown ask exactly the same + * question, and a fixture added at a fourth pair has to change one place rather than three. + * + * REASON. They disagreed. The guard and the sweep asked by person across every app; one test's + * cleanup asked by the anonymous actor across every app; the pair `("gmail", "")` was in none of + * them. So the file refused to run on rows it does not create, deleted rows it did not create, and + * left behind one that it did — three faces of one confusion about what makes a row this file's. + * The app is half the answer: every row here is at `gmail`, because a Composio app IS its toolkit + * slug and this file asserts things about the real one. + */ +function ownedConnections() { + return and( + eq(composioConnections.toolkit, "gmail"), + inArray(composioConnections.userId, ["user_asker", "user_leaver", ""]), + ); +} + beforeAll(async () => { /* * Stamped here, in the first hook the file registers, so no row this run writes is older than it @@ -274,20 +294,25 @@ beforeAll(async () => { .from(agents) .where(eq(agents.id, "bot_helper")), /* - * The anonymous actor is one of these ids too, but only at the app this file writes it - * against. + * Brokered connections, keyed on the PAIR rather than on the person. + * + * CRITERION. This guard refuses on `("gmail", )` and on no other + * `composio_connections` row, because those three pairs are the only ones this file inserts + * and the only ones it deletes. * - * CRITERION. This guard refuses on the PAIR `("gmail", "")` and on no other row at the - * anonymous actor, because that pair is the only one this file inserts and the only one it - * deletes. + * REASON. Every connection row this file writes is at `gmail` — the two people it invents + * and the anonymous actor alike — so the app is half of what makes a row this file's, and + * asking by person alone claims rows at every other app as well. Both spellings of that + * over-reach have already cost something. `user_id = ''` caught the anonymous row + * `composio-connections.test.ts` writes against its own run-suffixed app, so a run of that + * file killed before its cleanup refused every test here for good; `user_id IN (asker, + * leaver)` claims a `("slack", "user_asker")` row the same way, and `freshDatabase` would + * then DELETE it — a `composio_connections` row is the entire gate on a brokered call, and + * nothing else can find it again. * - * REASON. `composio_connections.user_id` is notNull and notNull does not exclude the empty - * string, so `("gmail", "")` is a row a deployment can legally hold — which is the whole - * point of the test that inserts one. Asked as `user_id = ''` alone, this also caught the - * anonymous row `composio-connections.test.ts` writes against its OWN run-suffixed app: a - * run of that file killed between its insert and its cleanup left a row nobody here owns, - * and every one of this file's tests then refused for good over it. Keyed on the pair, a - * stranded row belonging to another file is simply not this one's business. + * The anonymous actor is one of the three because `user_id` is notNull and notNull does not + * exclude the empty string, so `("gmail", "")` is a row a deployment can legally hold, which + * is the whole point of the test that inserts one. */ database .select({ @@ -295,15 +320,7 @@ beforeAll(async () => { userId: composioConnections.userId, }) .from(composioConnections) - .where( - or( - inArray(composioConnections.userId, ["user_asker", "user_leaver"]), - and( - eq(composioConnections.toolkit, "gmail"), - eq(composioConnections.userId, ""), - ), - ), - ), + .where(ownedConnections()), /* * The person, who was missing from this guard entirely. * @@ -3687,32 +3704,23 @@ async function freshDatabase(): Promise { await database .delete(mcpServers) .where(inArray(mcpServers.id, ["gmail", "notion"])); - // By person, never by toolkit: the table has no foreign key to `users` — which is the property the - // first test below is about, so nothing else removes these rows — and a delete by toolkit alone - // would take every person's Gmail connection, leaving one orphaned at the broker with no local row - // left to find it by. Only the two people this file invents. - await database.delete(composioConnections).where( - or( - inArray(composioConnections.userId, ["user_asker", "user_leaver"]), - /* - * And the one pair at the anonymous actor this file writes, which no person id names. - * - * CRITERION. Exactly `("gmail", "")`, never `user_id = ''` across every app: the second - * spelling reaches the anonymous row another file owns at its own run-suffixed app. - * - * REASON. The test that inserts this pair takes it back in a `finally`, which covers a - * failed assertion and not a killed process — and the row it would otherwise leave is - * precisely what the guard at the top of this file refuses on. Swept here, a pair stranded - * earlier in this same run is gone before the next test looks at it; a pair that was - * already there when the run started is still the guard's to refuse, because at that point - * nothing has established it is ours. - */ - and( - eq(composioConnections.toolkit, "gmail"), - eq(composioConnections.userId, ""), - ), - ), - ); + /* + * Brokered connections, by the pair and never by half of it. + * + * NEITHER HALF ALONE. By toolkit it would take every person's Gmail connection, leaving one + * orphaned at the broker with no local row to find it by — the table has no foreign key to + * `users`, which is the property the first test below is about, so nothing else would ever + * remove it. By person it would take a `("slack", "user_asker")` row belonging to somebody else, + * for the same reason and at the same cost. {@link ownedConnections} is what the guard at the + * top of this file has already established nothing else holds. + * + * The anonymous pair is swept here as well as in the `finally` of the test that inserts it, + * because that `finally` covers a failed assertion and not a killed process — and the row it + * would leave is what the guard refuses on. A pair stranded earlier in this run is therefore + * gone before the next test looks; a pair that was already there when the run started is still + * the guard's to refuse, because at that point nothing has established it is ours. + */ + await database.delete(composioConnections).where(ownedConnections()); // The person the connection outlives, who is a row in `users` like anybody else. Reached only // through the check at the top of this function, because there is no suffix on this id to tell a // fixture apart from somebody's account and ten cascades sit behind the difference. @@ -3862,9 +3870,21 @@ afterAll(async () => { await database .delete(mcpServers) .where(inArray(mcpServers.id, ["gmail", "notion"])); + await database.delete(composioConnections).where(ownedConnections()); + /* + * And the witness row, which is at neither `gmail` nor any person. + * + * CRITERION. Nothing at `sweep_witness_${suite}` outlives this run. + * + * REASON. It is removed in its own test's `finally`, which a killed process does not run — and + * nothing else would reach it: `ownedConnections` is keyed on `gmail`, and the anonymous actor + * is precisely what `retireConnectionsFor` refuses to act on, so no operation in the product + * could clear it either. Named exactly rather than by prefix, because another run's witness is + * that run's to take back. + */ await database .delete(composioConnections) - .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + .where(eq(composioConnections.toolkit, `sweep_witness_${suite}`)); await database.delete(agents).where(eq(agents.id, "bot_helper")); await database.delete(users).where(eq(users.id, "user_leaver")); }); @@ -3881,10 +3901,23 @@ test("a Composio connection row survives the person being deleted", async () => await database.delete(users).where(eq(users.id, "user_leaver")); + /* + * Asked at the app this test connected, not at every app this person might hold. + * + * The guard at the top of this file is keyed on the pair, so a `("slack", "user_leaver")` row + * belonging to somebody else is deliberately allowed to exist — and asking by person alone would + * then read it into this assertion and fail over a row that has nothing to do with the property + * under test. + */ const rows = await database .select({ toolkit: composioConnections.toolkit }) .from(composioConnections) - .where(eq(composioConnections.userId, "user_leaver")); + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, "user_leaver"), + ), + ); // The whole reason this table exists rather than reusing mcp_user_credentials: offboarding has to // still find the connection and revoke it at Composio after the person is gone, and there is no @@ -4151,9 +4184,19 @@ test("a Composio call whose row id and url name different apps is refused", asyn return vendorAnswered(); }, }); - // The row is called `gmail` and the person has connected `gmail`; the url dials Slack, which is - // the app the call would actually run in. - await seedComposioGmail(database, store, { url: "composio://slack" }); + /* + * The row is called `gmail` and the person has connected `gmail`; the url dials some OTHER app, + * which is the one the call would actually run in. + * + * SUITE-SCOPED, and that is not cosmetic. Spelled `slack`, this test asserted a refusal on the + * strength of `("slack", "user_asker")` not existing anywhere in the database — so it depended + * on this file owning a production person id at every app in the world, and a real deployment + * row at that pair would have turned the refusal into a completed call and read as this gate + * being broken. An app nobody can have connected is what the property actually needs. + */ + await seedComposioGmail(database, store, { + url: `composio://unconnected_${suite}`, + }); await expect( store.callTool({ From ba8519a5c5c4e7ad55fbb0bb1470d8b1fbadbac8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:06:14 +0200 Subject: [PATCH 87/93] Put a failed query on the shelf that is raised rather than relayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool-list replace was fixed at its own site; every other query of ours throws the same `DrizzleQueryError` — `Failed query:` plus the statement, then `params:` and every bound value — into a `catch` that copies `error.message` onward. On the call path those values are credential ids, user ids and server ids, and the message reached three audiences at once: a model's tool result ("That tool could not be called: "), the `failure` field of the `mcp.call_failed` audit row, and a signed-in browser as a 502 labelled `failed: true`, which also asserted a vendor had answered. On the refresh path it went into `lastError`, the column the Plugins page draws as what the vendor said. `isDeploymentFault` now answers for a query failure too, recognised by shape: `query` and `params` as own properties of an `Error`. By shape and not by class deliberately — drizzle's class is reachable only through a deep import that is not part of its published surface, and the shape is what makes the message dangerous. `withoutStatement` is what every site that copies a message now asks, and `deploymentFaultSentence` is what the admin route shows. The narrowing in the refresh's vendor `catch` was DEAD: it tested for `PluginInvariantError`, and its own comment named two throws that cannot arrive there — `connectionTokenFor` is not called for `composio`, the only brokered transport, and the `person-oauth` narrowing is unreachable because `accessFor` answers that credential only for a `user-oauth` entry. It could be deleted with every test green while the arrival it should have caught went past it into the column. It asks about the whole shelf now, and the query failure is the member that actually gets there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 6 +- server/src/plugins/store.ts | 107 ++++++- server/tests/plugin-routes.test.ts | 35 +++ server/tests/plugin-store.integration.test.ts | 273 ++++++++++++++++++ 4 files changed, 412 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 639b72d69..4b8868553 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -19,6 +19,7 @@ import { CustomServerRefusedError, type OAuthClient, type PluginKind, + deploymentFaultSentence, isDeploymentFault, PluginRefusedError, type PluginStore, @@ -349,7 +350,10 @@ export function createPluginRoutes( * what the sentence tells the reader to go and do. */ if (isDeploymentFault(error)) { - return context.json({ error: error.message }, 409); + // `deploymentFaultSentence` rather than `error.message`: the shelf now includes a query + // this database refused, and that one's message is the statement and every value bound to + // it. An administrator is entitled to the reason, not to the dump. + return context.json({ error: deploymentFaultSentence(error) }, 409); } throw error; } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 353c6eae8..166b5b81d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -289,6 +289,50 @@ export class PluginInvariantError extends Error { */ const NUL = "\u0000"; +/** + * Whether a throw is a query failure carrying the statement and the values bound to it. + * + * CRITERION. Anything this answers true for has a message that must never be relayed — not to a + * model, not to a browser, not into a column an operator reads. + * + * REASON. drizzle wraps every failure as a `DrizzleQueryError` and puts `Failed query: ` and `params: ` in its `message`. Along the tool-call path those + * values are credential ids, user ids and server ids; along the refresh path they are the vendor's + * entire tool list. + * + * BY SHAPE, NOT BY CLASS, and that is the one place this file departs from its own "tell them apart + * by a class, never by prose" rule. The class is drizzle's, reachable only through a deep import + * that is not part of its published surface, so an `instanceof` here would pin this deployment to + * an internal path a minor release may move. `query` and `params` as own properties on an `Error` + * is not prose — it is the shape the constructor assigns, it is what makes the message dangerous, + * and anything else carrying both fields is a query failure too. + */ +function isQueryFailure( + error: unknown, +): error is Error & { query: unknown; params: unknown } { + return ( + error instanceof Error && + Object.hasOwn(error, "query") && + Object.hasOwn(error, "params") + ); +} + +/** + * As much of a failure as may be shown to whoever is entitled to see it. + * + * CRITERION. Every place that copies a message out of a caught error asks this instead of reading + * `.message`. What comes back never contains a statement or a bound value. + * + * REASON. The message is the useful thing for a vendor's refusal, a person's missing connection or + * an invariant of ours — that is why those paths quote it, and they should go on quoting it. It is + * the wrong thing for exactly one kind of error, and that kind announces itself by shape. Asking + * here rather than at each site means a new audience cannot be added without the question already + * answered for it. + */ +function withoutStatement(error: Error): string { + return isQueryFailure(error) ? databaseComplaint(error) : error.message; +} + /** * The driver's own complaint about a query, without the query. * @@ -394,10 +438,27 @@ function storableTools(serverId: string, listed: ListedTool[]) { export function isDeploymentFault(error: unknown): error is Error { return ( error instanceof ServerUnresolvableError || - error instanceof PluginInvariantError + error instanceof PluginInvariantError || + /* + * A query this database refused is on the shelf for the reason the other two are: it is not a + * vendor's doing, it is not the asker's to act on, and its message is the one thing here that + * must not travel. The replace in `refreshTools` was fixed at its own site; every other query + * on the call path — the advertised-tool read, the connection gate, the vault read, the locked + * credential swap — throws the same shape into a `catch` that copies `error.message` onward, + * so answering it here is what makes the four audiences agree without four more branches. + * + * Callers that SHOW the sentence to an operator must still ask {@link withoutStatement} for + * it rather than reading `.message`; this predicate settles who may be told, not what. + */ + isQueryFailure(error) ); } +/** The operator-facing sentence for a fault on that shelf, with no statement in it. */ +export function deploymentFaultSentence(error: Error): string { + return withoutStatement(error); +} + /** * The vendor's `error` code, when a token endpoint refuses an exchange. * @@ -2492,14 +2553,34 @@ export function createPluginStore(options: PluginStoreOptions) { }); } catch (error) { /* - * The narrowing throws in `connectionTokenFor` are ours, not a vendor's. + * Ours rather than a vendor's, asked as one question about the whole shelf. * - * They fire for a row that resolved to a brokered credential with no app in its url, or to a - * per-person credential with no `user-oauth` entry — contradictions between this deployment's - * own tables and its own code. Raised rather than recorded, so the page does not send whoever - * reads it to somebody else's status page. + * CRITERION. Nothing on the `isDeploymentFault` shelf is written into `lastError`, and + * nothing raised from here carries a statement or a bound value. + * + * WHAT THIS USED TO BE, and why the difference is not cosmetic. It read `error instanceof + * PluginInvariantError` — which was DEAD, and its own comment named two throws that cannot + * arrive here: `connectionTokenFor` is only called when `transport.listNeedsCredential`, + * which is false for `composio`, the only brokered transport, so the brokered narrowing + * cannot fire inside this `try`; and the `person-oauth` narrowing is unreachable because + * `accessFor` answers that credential only for a `user-oauth` entry. So the line could be + * deleted with every test still green while the arrival it should have been catching — + * a query of ours failing — went straight past it into the column below. + * + * A QUERY FAILURE IS THE REACHABLE ONE. `connectionTokenFor`'s vault read, its connection + * lookup and its locked credential swap all run inside this `try` for an MCP listing, and + * each throws a `DrizzleQueryError` whose message is the statement plus every value bound + * to it. Recorded, that put a SQL dump in the column the Plugins page draws, under a + * heading that says a vendor said it. Raised as an invariant of ours, with the driver's + * complaint and none of the query. */ - if (error instanceof PluginInvariantError) throw error; + if (isDeploymentFault(error)) { + throw isQueryFailure(error) + ? new PluginInvariantError( + `${row.id}: asking this app what it offers failed on a query of this deployment's own, so nothing about the app was learned and nothing it holds was changed. ${databaseComplaint(error)}`, + ) + : error; + } const message = error instanceof McpServerError || error instanceof Error @@ -3819,8 +3900,18 @@ export function createPluginStore(options: PluginStoreOptions) { ...(input.initiator ? { initiator: input.initiator } : {}), payload: { ...decided, + /* + * Asked through {@link withoutStatement}, because not every throw in this block is a + * vendor's sentence. + * + * The vendor's own words are what this field is for and are kept. But every query on + * the way here throws a `DrizzleQueryError` whose message is our statement and its + * bound values — credential ids, user ids, server ids — and `audit_events` is read by + * an operator and exported. A dump in the row that records a failed call is the same + * disclosure the tool-list replace was fixed for, in the trail rather than on a page. + */ failure: (error instanceof Error - ? error.message + ? withoutStatement(error) : String(error) ).slice(0, 400), }, diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 205928e5f..84272ff0a 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -169,6 +169,41 @@ describe("refreshing a server that cannot be resolved", () => { expect(await response.json()).toEqual({ error: sentence }); }); + test("a failed query comes back as the reason, never as the statement", async () => { + /* + * The shape drizzle throws: `Failed query:` plus the whole statement, then `params:` and every + * value bound to it, with the driver's own error on `cause`. It is on the same shelf as the + * refusals above — not a vendor's doing, not the asker's to act on — so this route is where an + * operator is told about it, and it is the one member of that shelf whose `message` must not be + * what they are told. + */ + const request = refreshApp(async () => { + throw Object.assign( + new Error( + 'Failed query: select "credential_id" from "mcp_user_credentials" where "user_id" = $1 params: someone', + ), + { + query: 'select "credential_id" from "mcp_user_credentials"', + params: ["someone"], + cause: new Error("canceling statement due to statement timeout"), + }, + ); + }); + + const response = await request(); + expect(response.status).toBe(409); + const body = (await response.json()) as { error?: string }; + // The reason, which is what an administrator can act on. + expect(body.error).toContain( + "canceling statement due to statement timeout", + ); + // And none of the query. This route answers an administrator, but the browser it answers is + // still on somebody's laptop and the sentence still ends up in a screenshot and a ticket. + expect(body.error).not.toContain("Failed query"); + expect(body.error).not.toContain("params:"); + expect(body.error).not.toContain("mcp_user_credentials"); + }); + test("a failure that is not one of ours is still not dressed up as one", async () => { // The must-not case, the same one the add route above carries: a database that is down is not // a row an administrator can go and correct, and answering 409 would send them to do it. diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b702749df..6a28ac053 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -5439,6 +5439,279 @@ describe("a listing this database would not have taken", () => { }); }); +/** + * A QUERY OF OURS THAT FAILED, on the two paths that copy a caught message onward. + * + * WHAT THE SHAPE IS. drizzle wraps every failure as a `DrizzleQueryError`: `message` is `Failed + * query:` plus the whole statement, then `params:` and every value bound to it, with the driver's + * own error on `cause`. On the tool-call path those values are credential ids, user ids and server + * ids; on the refresh path they are the vendor's tool list. + * + * WHERE IT COMES FROM. A per-person MCP listing is the shape that runs a query of ours inside the + * block that catches the vendor's failures: `connectionTokenFor` reads the asking person's stored + * grant there. `composio` never gets that far — `listNeedsCredential` is false for it, the only + * brokered transport — and a server added by URL reads its one token from the vault rather than + * from a query. So the failure is injected at that one read and arrives exactly where it would in + * production, rather than being handed to the `catch` from somewhere it could not come from. + */ +describe("a query of this deployment's own that failed", () => { + /** The drizzle shape, spelled once: statement and bound values in `message`, driver on `cause`. */ + function queryFailure() { + return Object.assign( + new Error( + 'Failed query: select "credential_id" from "mcp_user_credentials" where "user_id" = $1 params: user_asker', + ), + { + query: 'select "credential_id" from "mcp_user_credentials"', + params: ["user_asker"], + cause: new Error("canceling statement due to statement timeout"), + }, + ); + } + + test("a refresh raises it rather than recording it as what the vendor said", async () => { + const { database } = await freshStore(); + + /* + * Notion, because a per-person MCP listing is the only shape that runs a query of ours inside + * the vendor `try`. `composio` never gets there — `listNeedsCredential` is false for it, so + * `connectionTokenFor` is not called at all — and a server added by URL reads its one token + * from the vault rather than from a query. Resolved from the catalogue rather than spelled, so + * a renamed slug breaks this file instead of quietly emptying it. + */ + const notion = catalogueEntry("notion"); + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here reaches a per-person listing", + ); + } + + // `user_leaver` rather than a new id: this file already owns a `users` row at it, so the + // person, the connection and the server row are all cleaned by machinery that exists. + await database.insert(users).values({ + id: "user_leaver", + email: "leaver@example.com", + name: "Leaver", + }); + const [grant] = await database + .insert(credentialRows) + .values({ + kind: "mcp_user_token", + provider: "notion", + keyId: "user_leaver", + encryptedValue: "{}", + metadata: {}, + }) + .returning({ id: credentialRows.id }); + if (!grant) throw new Error("grant row was not created"); + await database.insert(mcpServers).values({ + id: "notion", + title: notion.title, + vendor: notion.vendor, + url: `${notion.host}${notion.path}`, + provenance: "first-party", + }); + await database.insert(mcpUserCredentials).values({ + serverId: "notion", + userId: "user_leaver", + credentialId: grant.id, + scope: "", + }); + + /* + * The stored-grant read, failed — and nothing else. + * + * Derived from the real database so every other query the refresh makes is the real query. + * The second `select` is the one: `requireServer` reads the server row first, outside the + * block that catches vendor failures, and `connectionTokenFor`'s read of this person's grant + * is the next one and is inside it. Counting is what makes the failure land there rather than + * somewhere a blanket override would put it, and the assertions below distinguish the two — + * the row id in the message is added only by the conversion in that `catch`, so a failure + * escaping the earlier read would arrive as the raw dump and redden. + */ + let selects = 0; + const refusing: Database = Object.create(database); + Object.defineProperty(refusing, "select", { + value: (...args: never[]) => { + selects += 1; + if (selects === 2) throw queryFailure(); + return database.select(...args); + }, + }); + const failing = createPluginStore({ + database: refusing, + auditStore: { insert: async () => {} }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + let thrown: unknown; + try { + await failing.refreshTools("notion", "user_leaver"); + } catch (error) { + thrown = error; + } + + try { + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "notion")); + + /* + * Nothing in the column, asserted FIRST because it is the half that was actually broken and + * because its failure prints what leaked. + * + * `lastError` is drawn on the Plugins page beside a refresh that looks merely to have + * failed, and the narrowing meant to keep our own faults out of it tested for a class that + * cannot arrive inside that `try` at all — so it could be deleted with every test green + * while the statement and every value bound to it went into a column an operator reads and + * an export carries. + */ + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).toBeNull(); + + // Raised, because a query this database refused is not something the vendor did — the same + // criterion the replace further down this method is held to. + expect(isDeploymentFault(thrown)).toBe(true); + const message = thrown instanceof Error ? thrown.message : String(thrown); + expect(message).toContain("canceling statement due to statement timeout"); + expect(message).not.toContain("Failed query"); + expect(message).not.toContain("params:"); + expect(message).not.toContain("mcp_user_credentials"); + // Named by the conversion inside the refresh's own `catch`, which is how this asserts WHERE + // the failure was classified and not merely that something was thrown. + expect(message).toContain("notion:"); + } finally { + /* + * Locally, and in this order. `mcp_user_credentials.credential_id` is a real foreign key + * that deliberately does not cascade, so the join row has to go before the vault row — and + * the teardown that clears vault rows for this file runs before the one that clears server + * rows, which is what would otherwise leave a delete refusing. + */ + await database + .delete(mcpUserCredentials) + .where( + and( + eq(mcpUserCredentials.serverId, "notion"), + eq(mcpUserCredentials.userId, "user_leaver"), + ), + ); + await database + .delete(credentialRows) + .where(eq(credentialRows.id, grant.id)); + } + }); + + test("the model is told the call did not happen, and none of the query", async () => { + /* + * At the seam that decides, which is where the leak was. + * + * `grantedTools` takes a store, and the question is what it hands the model when that store + * throws — so the store is the thing stubbed and nothing else is. Every query on the call path + * runs inside `callTool`'s own recording block and comes out of it unchanged, so this shape + * arriving here is the production arrival, not an approximation of one. + */ + const [tool] = await grantedTools({ + store: { + listForAgent: async () => ({ + tools: [ + { + ref: "gmail/GMAIL_FETCH_EMAILS", + toolName: "gmail__GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputSchema: { type: "object", properties: {} }, + }, + ], + skills: [], + }), + callTool: async () => { + throw queryFailure(); + }, + } as unknown as PluginStore, + botId: "bot_helper", + actorId: "user_asker", + }); + if (!tool) throw new Error("the Bot was offered no tool to call"); + + const answer = await tool.execute({}); + + /* + * What the model is handed, exactly. + * + * Not the statement and not the values bound to it — on this path those are credential ids, + * user ids and server ids. Not a sentence blaming the vendor either: the call never reached + * one, and `That tool could not be called: ` is what the model used to be given to + * explain the failure to the person asking. + */ + expect(answer).toBe("That tool could not be called."); + expect(answer).not.toContain("Failed query"); + expect(answer).not.toContain("params:"); + expect(answer).not.toContain("mcp_user_credentials"); + }); + + test("the trail gets the reason and none of the query", async () => { + const database = await freshDatabase(); + const events: { eventType: string; payload: unknown }[] = []; + /* + * Thrown at the vendor seam, and recorded by the block above it. + * + * WHERE THIS ARRIVES FROM IN PRODUCTION: `connectionTokenFor`, three lines earlier and inside + * the same `try` — its connection gate read, its vault read and its locked credential swap + * are all queries of ours. Reaching one of those and failing only it needs a counted override + * of every `select` the call path makes, which pins a test to the order of queries rather than + * to the property. `callVendor` is the one seam this store hands a caller, and a throw through + * it lands in exactly the `catch` those queries land in; what that `catch` can do about a + * throw is classify it, which is the property. + */ + const failing = createPluginStore({ + database, + auditStore: { + insert: async (event) => { + events.push(event as (typeof events)[number]); + }, + }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + callVendor: async () => { + throw queryFailure(); + }, + }); + await seedComposioGmail(database, failing); + + await expect( + failing.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(); + + const failed = events.filter( + (event) => event.eventType === "mcp.call_failed", + ); + expect(failed).toHaveLength(1); + const failure = + (failed[0]?.payload as { failure?: string } | undefined)?.failure ?? ""; + /* + * The reason, because "is this connector working" is asked of this row and the driver's + * complaint answers it. Not the statement and not the values bound to it: on this path those + * are credential ids, user ids and server ids, and `audit_events` is read by an operator and + * carried out of the deployment by an export. + */ + expect(failure).toContain("canceling statement due to statement timeout"); + expect(failure).not.toContain("Failed query"); + expect(failure).not.toContain("params:"); + expect(failure).not.toContain("mcp_user_credentials"); + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From d09f91ff5e8392cd0c57721bcfc67780bbc80c03 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:07:43 +0200 Subject: [PATCH 88/93] Map a deployment fault on the routes that call the same method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a server refreshes it before answering — deliberately, so a bad credential is reported now rather than the first time a Bot uses it — so everything `refreshTools` raises arrives on the add routes too, and the two faults added in this branch are both reachable there: a vendor listing one action twice, and a query of ours failing. Registering an OAuth client resolves the row first, so a row whose columns contradict each other refuses there as well. All three mapped only `CatalogueEntryUnknownError` and `CustomServerRefusedError`, so the same fault was a named sentence on the refresh button and "That did not work" on the add form. Mapping one route and not its siblings is the shape that made this class hard to see the first time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 27 +++++++++++++++++++++++++++ server/tests/plugin-routes.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 4b8868553..14746b975 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -213,6 +213,23 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + /* + * The same mapping the refresh route makes, on the routes that call the same method. + * + * CRITERION. Every admin route whose store call can reach a fault on the + * `isDeploymentFault` shelf answers with the sentence rather than leaving it to the default + * handler. + * + * REASON. Adding a server REFRESHES it before answering — deliberately, so a bad credential + * is reported now rather than the first time a Bot uses it — so every fault `refreshTools` + * raises arrives here too, and a vendor listing one action twice or a query of ours failing + * is exactly that. Mapped on one route and not on its siblings, the same fault is a named + * sentence or "That did not work" depending on which button was pressed, which is the shape + * that made this class hard to see the first time. + */ + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); @@ -257,6 +274,11 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + // As on the curated add above, and for the same reason: this path refreshes before it + // answers. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); @@ -301,6 +323,11 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + // Registering a client resolves the row first, so a row this deployment cannot say how to + // reach refuses here as well. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 84272ff0a..529ba6311 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -5,6 +5,7 @@ import { ServerRowAmbiguousError } from "../src/plugins/access"; import { CatalogueEntryUnknownError, CustomServerRefusedError, + PluginInvariantError, } from "../src/plugins/store"; import { testEnvironment } from "./support/environment"; @@ -85,6 +86,29 @@ describe("adding a curated server", () => { expect((await request({ key: "nope" })).status).toBe(400); }); + test("a row the deployment cannot resolve comes back with its sentence", async () => { + /* + * ADDING REFRESHES, which is what puts this fault on this route. + * + * `addServer` asks the vendor what it offers before it answers — deliberately, so a bad + * credential is reported now rather than the first time a Bot uses one — so everything + * `refreshTools` raises arrives here as well: a vendor listing one action twice, a query of + * ours failing, a row whose two columns contradict each other. Unmapped, all of it left the + * route on the default path and the admin page said "That did not work", while the SAME fault + * on the refresh button said which row and what to do about it. + */ + const sentence = + "notion: the actions this app listed were not stored, so what it already had is unchanged."; + const request = appWith(async () => { + throw new PluginInvariantError(sentence); + }); + + const response = await request({ key: "notion" }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: sentence }); + }); + test("a failure that is not a refusal is not dressed up as one", async () => { // The must-not case. Mapping every throw to 400 would tell an administrator to correct their // input when the database is down, and would hide a real fault behind a message about From d4ed3d31a3121c7ab0867d9002c970d85ad969a9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:08:58 +0200 Subject: [PATCH 89/93] Cover the read that decides whether to revoke a credential again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `removeServer` reads liveness from `credentials` before asking the vault to revoke, and nothing asserted it. The test beside this one inserts a live row and takes the true branch; the one after it has no credential at all and never runs the query. So `isNull(revoked_at)` could be dropped with the whole suite green — while in production `credentials.revoke` throws "not found or already revoked", which propagates before `delete(mcpServers)` and leaves a server row no number of attempts can remove, on a DELETE route with no catch. A previous removal that failed after the revoke, or a key rotated by hand, both produce the row. Asserted as "the vault was not asked" rather than as the absence of a throw: the stub here is deliberately forgiving, so a test waiting for it to complain would pass with the clause gone. What the read decides is whether the call is made at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 6a28ac053..2adb355e0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -955,6 +955,80 @@ describe("removing an MCP server", () => { } }); + /** + * A credential that was already retired, and the read that decides whether to retire it again. + * + * CRITERION. `removeServer` must not ask the vault to revoke a credential whose row already + * carries a `revoked_at`, and must still remove the server row. + * + * REASON. It reads liveness from the table before deciding, and nothing asserted that. The test + * above inserts a LIVE row and so takes the true branch; the one below has no credential at all + * and so never runs the query. So `isNull(revoked_at)` could be dropped with the whole suite + * green — and in production `credentials.revoke` throws "not found or already revoked", which + * propagates before `delete(mcpServers)` and leaves a server row that cannot be removed by any + * number of attempts, on a route with no `catch`. Two ordinary states produce the row: a + * previous removal that failed after the revoke, and a key rotated by hand. + * + * ASSERTED AS "revoke was not called", not as the absence of a throw. The vault here is a stub + * that is deliberately forgiving — it stamps whatever id it is handed — so a test waiting for it + * to complain would pass with the clause gone. What the read decides is whether the call is made + * at all, and that is what {@link revokedCredentialIds} records. + */ + test("does not ask the vault to revoke a credential already revoked", async () => { + const removalServerId = `removal-target-retired-${suite}`; + revokedCredentialIds.length = 0; + const revokedAt = new Date(); + const [credentialRow] = await database + .insert(credentialRows) + .values({ + kind: "mcp", + provider: removalServerId, + keyId: `mcp-${removalServerId}`, + encryptedValue: "{}", + metadata: {}, + revokedAt, + updatedAt: revokedAt, + }) + .returning({ id: credentialRows.id }); + const credentialId = credentialRow?.id; + if (!credentialId) throw new Error("credential row was not created"); + issuedCredentialIds.push(credentialId); + await database.insert(mcpServers).values({ + id: removalServerId, + title: "removal target with a retired credential", + vendor: "test", + url: "https://example.invalid/mcp", + credentialId, + provenance: "custom", + }); + + await store.removeServer(removalServerId, "admin@openbot.local"); + + // Not asked, because the row already says it is retired. + expect(revokedCredentialIds).toEqual([]); + // And the server row is gone, which is the act an administrator asked for and the thing a + // throw from the vault would have prevented. + expect( + await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, removalServerId)), + ).toEqual([]); + // No second revocation in the trail either: a row saying access ended twice is a row an + // auditor has to reconcile against nothing having happened. + expect( + await database + .select({ id: auditEvents.id }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "credential.revoked"), + eq(auditEvents.targetId, credentialId), + ), + ), + ).toEqual([]); + }); + test("does not call revoke when the server had no credential", async () => { const removalServerId = `removal-target-nocred-${suite}`; revokedCredentialIds.length = 0; From 5c66242d50ad915c7558b8f471297c0cec5da413 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:14:17 +0200 Subject: [PATCH 90/93] Read the vendor call's fields by name in the stubs that record them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport's execute took its app, action, person and version positionally and now takes them as one named record, so slug and toolkit cannot silently transpose. These stubs still destructured positionally, which bound the whole record to what they called the slug — so the assertions about which person and which version reached the vendor were comparing against an object. Re-proved by mutation rather than by the suite going green: passing the Bot's id where the asker's belongs still reddens both identity tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-connections.test.ts | 2 +- server/tests/plugin-store.integration.test.ts | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index ad7772588..60ea85124 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -151,7 +151,7 @@ const answered: ComposioResult = { data: {}, error: null, successful: true }; function useAnsweringClient(actions: Partial = {}) { useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return answered; }, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 2adb355e0..1da146823 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -4030,7 +4030,7 @@ test("a Composio call with nobody attributed is refused before it reaches the ve const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4072,7 +4072,7 @@ test("a Composio call with nobody attributed is refused even when a connection r const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4155,7 +4155,7 @@ test("a Composio call by somebody who has not connected the app is refused with const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4198,7 +4198,7 @@ test("a Composio call by somebody who has not connected the app is refused even const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4226,7 +4226,7 @@ test("a Composio call whose url names no app is refused rather than falling back const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4253,7 +4253,7 @@ test("a Composio call whose row id and url name different apps is refused", asyn const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4293,7 +4293,7 @@ test("a Composio call sends the version recorded for that action", async () => { const calls: { slug: string; version: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, _userId, version) => { + execute: async ({ slug, version }) => { calls.push({ slug, version }); return vendorAnswered(); }, @@ -4326,7 +4326,7 @@ test("a version a model supplied in its own arguments cannot beat the recorded o const calls: { slug: string; version: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, _userId, version) => { + execute: async ({ slug, version }) => { calls.push({ slug, version }); return vendorAnswered(); }, @@ -4355,7 +4355,7 @@ test("a version a model supplied cannot stand in for an action with none recorde const calls: { slug: string; version: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, _userId, version) => { + execute: async ({ slug, version }) => { calls.push({ slug, version }); return vendorAnswered(); }, @@ -4411,7 +4411,7 @@ test("a Composio call reaches the vendor as the person asking, not as the Bot", const reached: { slug: string; userId: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, userId) => { + execute: async ({ slug, userId }) => { reached.push({ slug, userId }); return vendorAnswered(); }, @@ -4458,7 +4458,7 @@ test("an identity a model puts in the arguments does not change whose account th const reached: { userId: string; args: Record }[] = []; useComposioClient({ listActions: async () => [], - execute: async (_slug, userId, _version, args) => { + execute: async ({ userId }, args) => { reached.push({ userId, args }); return vendorAnswered(); }, From 848272b3324317cd9d1c222dbe15a3060da6b346 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:16:02 +0200 Subject: [PATCH 91/93] Stop the seam claiming an MCP server publishes no effect The paragraph said the optional fields on a listing were ones a broker publishes and an MCP server does not, and that mcp.ts read none of them. Both were false. The MCP specification defines a destructive hint, servers publish it, and it was being dropped -- so a tool its own vendor called destructive classified as a read wherever a curated write list had omitted it. Says what is true now, including which hint is deliberately not read and why: a hint may narrow what a Bot may do and may never widen it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/transport.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index cf225df8c..60a18b333 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -20,11 +20,19 @@ import * as mcp from "./mcp"; * * What has been added since is a SUPERSET of that shape rather than a departure from it, so an * MCP-shaped implementation still satisfies the seam unchanged. `listTools` answers `ListedTool[]`, - * which is `McpTool` plus optional fields a broker publishes and an MCP server does not; the - * connection carries an `actorId` and a `botId` for the transports whose authorization is the - * actor rather than a credential; and one reserved key on `args` hands a transport the recorded - * version of the action being called. Every addition is optional, which is why `mcp.ts` reads none - * of them and is still an implementation of this interface rather than an exception to it. + * which is `McpTool` plus fields describing what a listing said about an action; the connection + * carries an `actorId` and a `botId` for the transports whose authorization is the actor rather + * than a credential; and one reserved key on `args` hands a transport the recorded version of the + * action being called. Every addition is OPTIONAL, and that is what keeps an MCP-shaped + * implementation an implementation of this interface rather than an exception to it. + * + * This paragraph used to say those fields were ones "a broker publishes and an MCP server does + * not", and that `mcp.ts` therefore read none of them. Both were false: the MCP specification + * defines `annotations.destructiveHint`, servers do publish it, and `mcp.ts` was dropping it — so a + * tool a vendor declared destructive classified as a read wherever a curated write list omitted it. + * `mcp.ts` now reads that hint, and deliberately does not read `readOnlyHint`, because a hint may + * narrow what a Bot may do and may never widen it. The effect column is therefore not one + * transport's vocabulary; it is what any listing was willing to say. * * There are exactly two call sites in the whole system — the tool listing and the tool call — and * both take a transport from here. Nothing else reads a `TransportKind` at all: the OAuth flow, From 95012eb2624759bc47e297590eda65f3e2dab1b0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 14:18:11 +0200 Subject: [PATCH 92/93] Say that the initiator kind and the actor answer different questions The rebase brought main's initiator_kind alongside our actor field, and a run nobody can be named for now writes person as the initiator and unattributed as the actor. That reads as a contradiction and is not one: one field says what set the run in motion, the other says whose account it reached. Recorded rather than resolved. DEPLOYMENT_INITIATOR's own sentence claims the unidentifiable-caller case and would answer it the other way, but nothing sends it there, so the overlap is in the prose. Narrowing that sentence, or adding a third initiator kind, is not this branch's call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 166b5b81d..bbd8d3039 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -569,6 +569,20 @@ const iso = (value: Date | string | null): string | null => * * Neither is an address, so neither can collide with a user id: every actor written here otherwise * is `users.id` or the email a session resolved to. + * + * NOT THE SAME AXIS AS `initiator_kind`, and a row carrying both is not contradicting itself. + * `initiator_kind` answers what set a run in motion; this field answers whose account it reached + * and who can be named for it. So `initiator_kind: "person"` beside `actor: "unattributed"` reads + * correctly as a person-initiated request whose person this deployment could not identify. That is + * the honest reading, and it is the reason this is NOT recorded as `deployment`: that would assert + * the call went out on the deployment's own credential, and it did not go out at all. + * + * `DEPLOYMENT_INITIATOR`'s own doc claims the case of "refusing a caller it could not identify", + * which overlaps this one and would answer it the other way. Nothing sends it there — the tool path + * defaults its initiator to person and `identifyActor` returns an empty id rather than a deployment + * — so the overlap is in the prose, not in the behaviour. It is left alone deliberately rather than + * resolved by widening either vocabulary unilaterally; whoever owns that constant should narrow its + * sentence, or a third initiator kind should exist, and neither is this branch's call to make. */ const DEPLOYMENT_ACTOR = "deployment"; const UNATTRIBUTED_ACTOR = "unattributed"; From 21218e2266770bc5c9e0d5e957962cd186e3a88a Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 15:31:35 +0200 Subject: [PATCH 93/93] Sort the import members this branch appended out of order Three files gained members appended after the type members of an existing import rather than in sorted position. No workflow runs biome's assist domain, so nothing would have caught it, and the pre-push gate found it by measuring this branch against main rather than by trusting that the findings were old. Applied by biome's own safe fix; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 4 ++-- server/src/plugins/store.ts | 10 +++++----- server/tests/plugin-store.integration.test.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 14746b975..68c54eba9 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -17,10 +17,10 @@ import { import { CatalogueEntryUnknownError, CustomServerRefusedError, - type OAuthClient, - type PluginKind, deploymentFaultSentence, isDeploymentFault, + type OAuthClient, + type PluginKind, PluginRefusedError, type PluginStore, } from "./store"; diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index bbd8d3039..5c0d5373b 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -32,6 +32,11 @@ import { skills, skillTools, } from "../db/schema"; +import { + accessFor, + type ServerAccess, + ServerUnresolvableError, +} from "./access"; import { type CatalogueEntry, catalogueEntry, @@ -40,11 +45,6 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; -import { - accessFor, - type ServerAccess, - ServerUnresolvableError, -} from "./access"; import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; import { type ListedTool, McpServerError } from "./mcp"; diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1da146823..c9b44055f 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -44,21 +44,21 @@ import { useComposioClient, } from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; -import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { type AccessToken, CustomServerRefusedError, createPluginStore, exchangeRefreshTokenOverHttp, INVALID_CLIENT, + isDeploymentFault, type OAuthClient, PluginInvariantError, PluginRefusedError, type PluginStore, - isDeploymentFault, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { TEST_POOL } from "./support/database"; /**