diff --git a/bun.lock b/bun.lock index b116c2a30..462f1e57f 100644 --- a/bun.lock +++ b/bun.lock @@ -79,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", @@ -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/package.json b/package.json index c365827c4..cbb29bf31 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": "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" }, 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 } ] } diff --git a/server/package.json b/server/package.json index 02eb051bc..8a963497c 100644 --- a/server/package.json +++ b/server/package.json @@ -28,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" diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 2c887bbd2..db1e8f82d 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -105,11 +105,105 @@ 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] })], ); +/** + * 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. * diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts new file mode 100644 index 000000000..825b5acad --- /dev/null +++ b/server/src/plugins/access.ts @@ -0,0 +1,256 @@ +import type { CatalogueEntry } from "./catalogue"; +import { toolkitOf } from "./composio"; +import type { TransportKind } from "./transport"; + +/** + * 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 + * 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. + * + * 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 + * 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 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"; + /** + * 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< + CatalogueEntry["auth"]["kind"], + CredentialSource +> = { + none: "none", + "deployment-bearer": "deployment-token", + "user-oauth": "person-oauth", + 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", +}; + +/** + * 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. + * + * 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 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. + * + * 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. + */ +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) { + 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], + reachedAs: REACHED_AS_BY_AUTH[entry.auth.kind], + toolkit: null, + }; + } + + if (row.provenance === "composio") { + return { + transport: "composio", + credential: "brokered", + reachedAs: "person", + toolkit: toolkitOf(row.url), + }; + } + + return { + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + toolkit: null, + }; +} diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index b8445bfce..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. */ @@ -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[]; /** @@ -127,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; }; @@ -244,9 +253,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,25 +372,68 @@ 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. + * 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. * - * 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 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 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, toolName: string, advertised: boolean, + /** + * What the vendor said about this action when it was listed, or null when nothing did. + * + * 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. + * + * 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 neither later source can rescue a name that was never advertised. + if (!advertised) return "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"; - if (!advertised) 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/src/plugins/composio.ts b/server/src/plugins/composio.ts new file mode 100644 index 000000000..6f250a1ab --- /dev/null +++ b/server/src/plugins/composio.ts @@ -0,0 +1,828 @@ +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 + * 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. + */ + +/** + * 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"; + +/** + * 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; + description?: string; + /** + * 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[]; + /** The version calling this action requires — `20260903_00` and the like. */ + 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 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. + */ +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 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 + * 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 = { + /** + * 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; + /** + * 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( + 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; +}; + +let installed: ComposioActions | null = null; + +/** + * 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 + * 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; +} + +/** + * 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; + +/** + * 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. + * + * 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).trim().replace(/\/+$/, ""); + return TOOLKIT_SLUG.test(slug) ? slug : null; +} + +/** + * 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 }; +} + +/** 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. + * + * 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); + 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", + "additionalProperties", + "not", + "if", + "then", + "else", + ]) { + 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. 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 + * 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) { + 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 { + 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. + * + * 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 }); + } + + /* + * 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. + * + * `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.`, + ); + } + + /* + * 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); + /* + * 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, + ...(version ? { 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, 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; + const sentence = typeof message === "string" ? message.trim() : ""; + return sentence === "" ? null : sentence; +} + +/** + * 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. + * + * 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. + * + * 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)) { + 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 === "" || VENDOR_PLACEHOLDER.test(thrown) + ? `Composio did not answer with an action list for ${toolkit}.` + : thrown) + ); +} + +/** + * 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 => ({ + ...cap(message), + isError: true, +}); + +/** + * 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: 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 { ...cap(text), isError: false }; +} + +/** + * 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". + */ +function reportedFailure( + answer: ComposioResult, + toolName: string, +): string | 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; +} + +/** + * 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. + * + * 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 + * 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 }, + 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. + * + * 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: 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.`, + ); + } + + /* + * 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 { + 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() : ""; + return failure( + vendorSentence(error) ?? + (thrown === "" || VENDOR_PLACEHOLDER.test(thrown) + ? unexplained(toolName) + : thrown), + ); + } + + /* + * 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); + + 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/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 99b49bb35..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 ./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 diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 23e5599d7..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. @@ -85,6 +86,33 @@ 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 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. + */ +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); @@ -255,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, @@ -265,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/src/plugins/routes.ts b/server/src/plugins/routes.ts index 8fdc88535..68c54eba9 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -17,6 +17,8 @@ import { import { CatalogueEntryUnknownError, CustomServerRefusedError, + deploymentFaultSentence, + isDeploymentFault, type OAuthClient, type PluginKind, PluginRefusedError, @@ -211,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; } }); @@ -255,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; } }); @@ -299,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; } }); @@ -330,6 +359,29 @@ 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)) { + // `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; } }); @@ -879,6 +931,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 63d611f34..5c0d5373b 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, @@ -31,6 +32,11 @@ import { skills, skillTools, } from "../db/schema"; +import { + accessFor, + type ServerAccess, + ServerUnresolvableError, +} from "./access"; import { type CatalogueEntry, catalogueEntry, @@ -39,8 +45,9 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; +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"; @@ -221,6 +228,237 @@ 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"; + } +} + +/** + * 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. + */ +/** + * 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"; + +/** + * 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. + * + * 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, + /* + * 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 + * 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 || + 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. * @@ -291,8 +529,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 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 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. */ @@ -312,21 +553,56 @@ 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. + * 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. * - * 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` 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. * - * `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. + * 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 reachedAsFor = (entry: CatalogueEntry | null, actorId: string): string => - entry?.auth.kind === "user-oauth" || entry?.auth.kind === "builtin" - ? actorId - : "deployment"; +const DEPLOYMENT_ACTOR = "deployment"; +const UNATTRIBUTED_ACTOR = "unattributed"; + +/** + * Whose account this call went out as, for the trail. + * + * 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. + * + * 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 || UNATTRIBUTED_ACTOR + : DEPLOYMENT_ACTOR; /** * Where this server actually is, when the stored row and the catalogue disagree. @@ -752,28 +1028,121 @@ 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-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 `deployment-bearer` server this is what it always was: the one credential an administrator - * gave the server, used for everybody. + * 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. 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 `user-oauth` server it is the asker's own, and every branch that cannot prove it has the + * 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; credentialId: string | null }, + row: { id: 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 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 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. + */ + 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, + ); + } + + /* + * 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 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. 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 PluginInvariantError( + `${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, access.toolkit), + 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, @@ -783,6 +1152,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 PluginInvariantError( + `${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. * @@ -1585,6 +1972,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 @@ -1593,8 +1981,14 @@ 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. - 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 { @@ -1862,6 +2256,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 @@ -1869,7 +2269,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)); @@ -1953,6 +2359,89 @@ 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. + * + * 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, null).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", + /* + * 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: 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. + 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", @@ -1986,12 +2475,73 @@ 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); + /* + * 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); + + /* + * 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. * @@ -2008,120 +2558,44 @@ 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({ + listed = await transport.listTools({ url: effectiveUrl(row, entry), token, }); - + } catch (error) { /* - * 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. + * Ours rather than a vendor's, asked as one question about the whole shelf. * - * 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, - })), - ); - } - }); - - 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. + * CRITERION. Nothing on the `isDeploymentFault` shelf is written into `lastError`, and + * nothing raised from here carries a statement or a bound value. * - * 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. + * 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. * - * 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. + * 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. */ - 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.", - }, - }); - } - - /* - * 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.", - }, - }); + 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; } - return { tools: tools.length }; - } catch (error) { const message = error instanceof McpServerError || error instanceof Error ? error.message @@ -2146,6 +2620,211 @@ 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. 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. 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, 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 + .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, 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)); + // 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. + */ + // 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) + .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. + */ + // 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)); + + if (stranded.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: auditActor, + 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: auditActor, + 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.", + }, + }); + } + + // 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 { @@ -2203,7 +2882,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) ?? [], }; }), @@ -2772,6 +3451,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 @@ -2828,6 +3512,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 }; }, @@ -2887,6 +3627,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, { @@ -2895,7 +3646,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, @@ -2906,23 +3657,59 @@ 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 }) + .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, 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. + * + * 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 + * 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 + ? { ...modelArgs, [VERSION_ARG]: advertised[0].version } + : modelArgs; + /** * The same policy the computer actions are judged by, asked about a tool call. * @@ -2966,7 +3753,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, @@ -2978,7 +3765,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, @@ -3066,8 +3853,14 @@ export function createPluginStore(options: PluginStoreOptions) { * it did. */ try { - const { token } = await connectionTokenFor(row, entry, input.actorId); - const vendor = injectedVendor ?? transportFor(entry).callTool; + const { token } = await connectionTokenFor( + row, + entry, + input.actorId, + access, + ); + const vendor = + injectedVendor ?? transportFor(access.transport).callTool; const result = await vendor( { url: effectiveUrl(row, entry), @@ -3076,7 +3869,7 @@ export function createPluginStore(options: PluginStoreOptions) { botId: input.botId, }, toolName, - args, + vendorArgs, ); await recordAuditEvent(auditStore, { eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded", @@ -3121,8 +3914,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/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 4fecdb325..60a18b333 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -1,34 +1,57 @@ 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"; /** - * 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 * 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 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, 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,17 +64,23 @@ 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; + }): Promise; callTool( connection: { url: string; @@ -59,10 +88,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. */ @@ -74,27 +109,53 @@ 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" + | "google-drive-rest" + | "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 TransportKind = "mcp" | "google-drive-rest" | "builtin-routines"; +export type CuratedTransportKind = Exclude; const TRANSPORTS: Record = { mcp, "google-drive-rest": driveRest, "builtin-routines": builtinRoutines, + composio, }; /** - * 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/composio-access.test.ts b/server/tests/composio-access.test.ts new file mode 100644 index 000000000..06b59497c --- /dev/null +++ b/server/tests/composio-access.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "bun:test"; +import { accessFor, ServerRowAmbiguousError } from "../src/plugins/access"; +import type { CatalogueEntry } from "../src/plugins/catalogue"; +import { catalogueEntry, resolveServerUrl } 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", 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", url: "https://mcp.example.com/mcp" }, + null, + ), + ).toEqual({ + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + toolkit: null, + }); + }); + + test("Notion is MCP, on the asking person's own grant", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + const notionUrl = `${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; + const driveUrl = `${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", () => { + // 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: routines.url }, + routines.entry, + ), + ).toEqual({ + transport: "builtin-routines", + credential: "none", + reachedAs: "person", + toolkit: null, + }); + }); + + 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.", + // 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: [], + docsUrl: "https://example.com/docs", + }; + expect( + accessFor( + { + provenance: "first-party", + url: `${publicEntry.host}${publicEntry.path}`, + }, + 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"); + // 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. + // + // `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: "custom", url: "composio://gmail" }, + notion, + ); + expect(shadowed.transport).toBe("mcp"); + expect(shadowed.credential).toBe("person-oauth"); + 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 + // 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/composio-classify.test.ts b/server/tests/composio-classify.test.ts new file mode 100644 index 000000000..a0d6ab9a4 --- /dev/null +++ b/server/tests/composio-classify.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +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. 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", () => { + 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"); + }); +}); + +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/composio-connections.test.ts b/server/tests/composio-connections.test.ts new file mode 100644 index 000000000..60ea85124 --- /dev/null +++ b/server/tests/composio-connections.test.ts @@ -0,0 +1,563 @@ +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}`; +/** + * 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"] }; + +/** + * 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(inArray(mcpTools.serverId, [toolkit, renamedId])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, [toolkit, renamedId])); + 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. + * + * 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 }); + await database.insert(composioConnections).values({ toolkit, userId: "" }); + + expect((await store.retireConnectionsFor("", admin)).retired).toBe(0); + 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. + * + * 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, + }); +}); + +/** + * 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. + * + * 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]); +}); + +/** + * 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], + }); +}); diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts new file mode 100644 index 000000000..62e35198c --- /dev/null +++ b/server/tests/composio-live.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import { + Composio, + ComposioError, + ComposioToolVersionRequiredError, + type ToolExecuteResponse, +} 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. + * + * 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. 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, + // 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, + // 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({ + toolkits: ["gmail"], + // Explicit, because their default page is 20 and Gmail has 63. + limit: 500, + }); + + 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", + ), + ); + expect(unlabelled).toEqual([]); + + 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, + }); + // 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: NOBODY, + arguments: {}, + version, + }); + } 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); + }); + + /** + * 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({ + toolkits: ["gmail"], + limit: 1, + }); + if (!action) { + throw new Error( + "Precondition not met: Composio listed no Gmail action, so there was nothing to call. Nothing below was exercised.", + ); + } + + // 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: NOBODY, + arguments: {}, + version, + }); + } catch (error) { + thrown = error; + } + 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, + ); + }); +}); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts new file mode 100644 index 000000000..72409569d --- /dev/null +++ b/server/tests/composio-transport.test.ts @@ -0,0 +1,1371 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + type ComposioAction, + type ComposioActions, + type ComposioResult, + callTool, + effectOf, + LISTING_LIMIT, + listNeedsCredential, + listTools, + toolkitOf, + 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. + * + * 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 = { + toolkit: string; + 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, + }; +} + +/** + * The two numbers this file reasons about, WRITTEN OUT rather than imported. + * + * 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 = 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 { + return { cause: { error: { error: { message } } } }; +} + +function recording(answers: Partial = {}): { + client: ComposioActions; + calls: Recorded[]; +} { + const calls: Recorded[] = []; + return { + calls, + client: { + listActions: answers.listActions ?? (async () => []), + execute: + answers.execute ?? + (async (call) => { + calls.push({ ...call }); + return answered({ 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", +}; + +/** 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"); + 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(); + }); + + 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", () => { + 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(); + }); + + 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("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 + // 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", () => { + test("listing needs no credential", () => { + 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: WHOLE_LISTING } }, + ]); + }); + + 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: WHOLE_LISTING }, (_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) => { + 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 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("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 + * 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", + }; + /* + * 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 }], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + expect(tool?.inputSchema).toEqual(asHandedOver); + expect(schema).toEqual(asHandedOver); + }); + + 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("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 — + // 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("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" }); + + // 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"); + expect(asked).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"); + }); + + 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"); + } + }); + + 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", () => { + 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([ + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_asker", + version: "20260903_00", + }, + ]); + 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 (_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", + 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 () => { + 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([]); + + // 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 () => { + 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([ + { + toolkit: "gmail", + 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 () => answered({ body: "x".repeat(60_000) }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __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.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 () => { + useComposioClient(recording({ execute: async () => answered({}) }).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`. + // `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 () => { + // `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); + // `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 () => { + 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("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({ + 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); + // "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 () => { + 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.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 () => { + 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"); + }); + + 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); + } + }); +}); diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 0cf1cbdf1..9130febcc 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -1,6 +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 { type McpTool, callTool as mcpCallTool } from "../src/plugins/mcp"; import { transportFor } from "../src/plugins/transport"; /** @@ -17,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. */ @@ -41,29 +70,123 @@ 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. - expect(transportFor(entry).callTool).toBe(callTool); + expect( + transportFor( + accessFor( + { provenance: "first-party", url: "https://www.googleapis.com" }, + 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 — and asserted + // as MCP rather than as "not Drive", which any wrongly resolved kind would also satisfy. + expect( + transportFor( + accessFor( + { provenance: "custom", url: "https://mcp.example.com/mcp" }, + null, + ).transport, + ).callTool, + ).toBe(mcpCallTool); }); 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", () => { 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"); + }); +}); diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index d6b533a4a..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, ); @@ -543,3 +552,47 @@ 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"); + // 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"); +}); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 14c072769..529ba6311 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -1,9 +1,11 @@ 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, + PluginInvariantError, } from "../src/plugins/store"; import { testEnvironment } from "./support/environment"; @@ -84,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 @@ -104,6 +129,125 @@ 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 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. + 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 b6f442e94..c9b44055f 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, @@ -7,8 +8,9 @@ import { test, } from "bun:test"; import { randomUUID } from "node:crypto"; -import { MCPMock } from "@copilotkit/aimock/mcp"; -import { and, eq, inArray, like, sql } from "drizzle-orm"; +import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; +import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -17,10 +19,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, @@ -29,7 +32,17 @@ 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 { type AccessToken, @@ -37,11 +50,15 @@ import { createPluginStore, exchangeRefreshTokenOverHttp, INVALID_CLIENT, + isDeploymentFault, type OAuthClient, + PluginInvariantError, PluginRefusedError, + type PluginStore, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { TEST_POOL } from "./support/database"; /** @@ -71,56 +88,104 @@ 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[] = []; + +/** + * 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, }); +/** + * 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({ @@ -134,10 +199,164 @@ async function auditRowsFor(targetId: string) { and( eq(auditEvents.targetType, "mcp_tool"), eq(auditEvents.targetId, targetId), + sinceThisRun(), ), ); } +/** + * 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 `suiteCreatedServerRow` and its siblings are for. + * 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 + * 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. 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. + * + * 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 — `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. + */ +/** + * 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 + * 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 + .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")), + /* + * 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. + * + * 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. + * + * 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({ + toolkit: composioConnections.toolkit, + userId: composioConnections.userId, + }) + .from(composioConnections) + .where(ownedConnections()), + /* + * 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 = [ + ...configuredServers.map((row) => `the mcp_servers row '${row.id}'`), + ...existingBots.map((row) => `the Bot '${row.id}'`), + ...existingConnections.map( + (row) => + `the composio_connections row ('${row.toolkit}', '${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, " + + "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.", + ); + } + + ownsFixtureIds = true; +}); + beforeAll(async () => { for (const id of [holderId, strangerId]) { await database @@ -151,15 +370,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 }) @@ -167,7 +386,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. @@ -230,12 +449,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)); } @@ -492,7 +711,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 @@ -733,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; @@ -752,6 +1048,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'`, @@ -764,8 +1068,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 @@ -1113,14 +1419,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 @@ -1146,7 +1462,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 @@ -1188,11 +1504,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)); } @@ -1213,7 +1533,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)); @@ -1607,6 +1927,80 @@ 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, + /* + * 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(); + 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. * @@ -1677,7 +2071,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 = { @@ -1699,6 +2101,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; + }, }; /** @@ -1874,6 +2285,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(), ), ); } @@ -1907,9 +2320,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. @@ -1941,7 +2360,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 @@ -1979,11 +2398,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)); } @@ -2003,7 +2425,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)); @@ -2428,9 +2850,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); @@ -2439,65 +2860,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 }) @@ -2507,6 +2893,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 ?? []); @@ -2516,6 +2910,199 @@ 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 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 + * 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 + * 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]); + + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-fetch", + description: "A read no write list names.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, + }, + ], + 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" }, + ]); + }, + ); + }); + + /** + * 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. * @@ -2829,12 +3416,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, ]), ); @@ -3132,3 +3723,2161 @@ 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 the guard at the top of this file, not the ordering below. The deletes + * 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. + * + * 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"])); + /* + * 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. + 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}. + */ +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. + * + * `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; url?: string } = {}, +) { + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: options.url ?? "composio://gmail", + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: "gmail", + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + effect: "read", + version: options.version === undefined ? "20260903_00" : options.version, + }); + 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", + ); +} + +/** + * 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, +}); + +/** + * 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)); + +/* + * 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 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. + */ +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(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(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")); +}); + +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")); + + /* + * 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( + 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 + // vault row to find it by, because Composio holds the account. + 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 () => vendorAnswered(), + }); + 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("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 vendorAnswered(); + }, + }); + 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([]); +}); + +/** + * 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 vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + 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( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "", + }), + ).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 { + // 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( + and( + eq(composioConnections.toolkit, "gmail"), + 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[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + 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([]); +}); + +/** + * 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[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + // 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[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + /* + * 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({ + 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 }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug, version }) => { + calls.push({ slug, version }); + return vendorAnswered(); + }, + }); + 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" }, + ]); +}); + +/* + * 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 }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug, version }) => { + calls.push({ slug, version }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + // 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: supplying the reserved key changed + // nothing about which revision ran. + expect(calls).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, + ]); +}); + +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, version }) => { + calls.push({ slug, version }); + return vendorAnswered(); + }, + }); + // 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({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: { __version: "19700101_00" }, + botId: "bot_helper", + actorId: "user_asker", + }); + + // 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, + // 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( + /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/, + ); +}); + +/* + * 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" }, + ]); +}); + +/** + * 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 ({ userId }, 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({ + listActions: async () => [], + execute: async () => vendorAnswered({ 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" }); +}); + +/** + * 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(); + + 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 }); +}); + +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 () => 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. + 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 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_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 + // 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" }]); +}); + +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 () => vendorAnswered(), + }); + + 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", + }, + ]); +}); + +/** + * 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 () => vendorAnswered(), + }); + 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", + ]); +}); + +/* + * 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 `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. + * + * 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 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); + + 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")); + + /* + * 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(); + }); + + 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 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([]); + }); +}); + +/** + * 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, + }); +}); + +/** + * 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" }]); + }); +}); + +/** + * 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. + * + * 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 () => vendorAnswered(), + }); + 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 () => 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. + 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(); +});