diff --git a/doc/public-mcp.md b/doc/public-mcp.md index cf0349de56c..92d803fbd63 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -86,8 +86,8 @@ authenticated browser and the configured origin. Client registration does not fetch redirect URLs or accept arbitrary tool destinations. Rate limiting at the public edge is required in addition to the bounded per-process auth limiter. Registration also has a database-enforced limit of 60 new clients per minute and -10,000 retained clients shared across replicas. Expired authorization requests -and never-consented clients older than 24 hours are collected during +10,000 unconsented clients shared across replicas. Expired authorization requests +and never-consented clients older than one hour are collected during registration. Clients with grants are retained, preserving their connections and audit/mutation history. @@ -168,3 +168,10 @@ CLI login and local package validation do not replace these release gates. External sessions acting as agents, task leases and third-party granted tools remain the second/third releases; do not add generic executors to this public catalog to implement them. + +Registration also enforces shared source quotas (6 per minute and 30 unconsented +clients) using a resource-bound hash of the trusted request IP; raw addresses are +not stored. Configure trusted proxies correctly. Authorization starts atomically +remove expired requests and enforce 10 retained requests per client and 1,000 +instance-wide, independently of further client registrations. Existing grants +remain usable when anonymous registration or authorization is throttled. diff --git a/packages/db/src/migrations/0295_slimy_thunderbird.sql b/packages/db/src/migrations/0295_slimy_thunderbird.sql new file mode 100644 index 00000000000..0338246d8d1 --- /dev/null +++ b/packages/db/src/migrations/0295_slimy_thunderbird.sql @@ -0,0 +1 @@ +ALTER TABLE "mcp_oauth_clients" ADD COLUMN IF NOT EXISTS "registration_source_hash" text; diff --git a/packages/db/src/migrations/meta/0290_snapshot.json b/packages/db/src/migrations/meta/0295_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0290_snapshot.json rename to packages/db/src/migrations/meta/0295_snapshot.json index 63ef4c6fddf..ed653cc2f94 100644 --- a/packages/db/src/migrations/meta/0290_snapshot.json +++ b/packages/db/src/migrations/meta/0295_snapshot.json @@ -1,6 +1,6 @@ { - "id": "26315a87-4675-4dea-9634-efe5e878cc7d", - "prevId": "3960ae6b-5bf1-4fd7-91db-6a18fc7eaa19", + "id": "53879e0a-b064-4517-adb2-a9b5840a1903", + "prevId": "b5e04685-e9b6-4ca2-a6b6-f8f1e5dc454a", "version": "7", "dialect": "postgresql", "tables": { @@ -12737,6 +12737,373 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_source_entries": { + "name": "company_skill_source_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inspection": { + "name": "inspection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "present": { + "name": "present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_skill_source_entries_source_path_idx": { + "name": "company_skill_source_entries_source_path_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_source_entries_company_skill_idx": { + "name": "company_skill_source_entries_company_skill_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_source_entries_company_id_companies_id_fk": { + "name": "company_skill_source_entries_company_id_companies_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_source_id_company_skill_sources_id_fk": { + "name": "company_skill_source_entries_source_id_company_skill_sources_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skill_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_skill_id_company_skills_id_fk": { + "name": "company_skill_source_entries_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_sources": { + "name": "company_skill_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_ref": { + "name": "tracking_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "excluded_folders": { + "name": "excluded_folders", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scan_commit": { + "name": "last_scan_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_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": { + "company_skill_sources_repository_ref_idx": { + "name": "company_skill_sources_repository_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_sources_identity_ref_idx": { + "name": "company_skill_sources_identity_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_sources_company_id_companies_id_fk": { + "name": "company_skill_sources_company_id_companies_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_sources_connection_id_tool_connections_id_fk": { + "name": "company_skill_sources_connection_id_tool_connections_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_comments": { "name": "company_skill_comments", "schema": "", @@ -31098,6 +31465,13 @@ "primaryKey": false, "notNull": true }, + "title_needs_generation": { + "name": "title_needs_generation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "description": { "name": "description", "type": "text", @@ -49921,6 +50295,607 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "public.mcp_mutation_receipts": { + "name": "mcp_mutation_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "arguments_hash": { + "name": "arguments_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_mutation_receipts_request_uq": { + "name": "mcp_mutation_receipts_request_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_mutation_receipts_company_idx": { + "name": "mcp_mutation_receipts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_mutation_receipts_company_id_companies_id_fk": { + "name": "mcp_mutation_receipts_company_id_companies_id_fk", + "tableFrom": "mcp_mutation_receipts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_mutation_receipts_user_id_user_id_fk": { + "name": "mcp_mutation_receipts_user_id_user_id_fk", + "tableFrom": "mcp_mutation_receipts", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_mutation_receipts_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_mutation_receipts_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_mutation_receipts", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registration_source_hash": { + "name": "registration_source_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_grants": { + "name": "mcp_oauth_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "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()" + } + }, + "indexes": { + "mcp_oauth_grants_user_company_idx": { + "name": "mcp_oauth_grants_user_company_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_grants_company_id_companies_id_fk": { + "name": "mcp_oauth_grants_company_id_companies_id_fk", + "tableFrom": "mcp_oauth_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_grants_user_id_user_id_fk": { + "name": "mcp_oauth_grants_user_id_user_id_fk", + "tableFrom": "mcp_oauth_grants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_grants_client_id_mcp_oauth_clients_id_fk": { + "name": "mcp_oauth_grants_client_id_mcp_oauth_clients_id_fk", + "tableFrom": "mcp_oauth_grants", + "tableTo": "mcp_oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_requests": { + "name": "mcp_oauth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": { + "mcp_oauth_requests_code_uq": { + "name": "mcp_oauth_requests_code_uq", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_requests_expiry_idx": { + "name": "mcp_oauth_requests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_requests_client_id_mcp_oauth_clients_id_fk": { + "name": "mcp_oauth_requests_client_id_mcp_oauth_clients_id_fk", + "tableFrom": "mcp_oauth_requests", + "tableTo": "mcp_oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_requests_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_oauth_requests_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_oauth_requests", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_tokens": { + "name": "mcp_oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": { + "mcp_oauth_tokens_hash_uq": { + "name": "mcp_oauth_tokens_hash_uq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_tokens_grant_idx": { + "name": "mcp_oauth_tokens_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_tokens_expiry_idx": { + "name": "mcp_oauth_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_tokens_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_oauth_tokens_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_oauth_tokens", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": {}, diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index cf017d332ff..f2d76b6e8ab 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2052,6 +2052,13 @@ "when": 1790866454887, "tag": "0294_nervous_caretaker", "breakpoints": true + }, + { + "idx": 295, + "version": "7", + "when": 1790867992285, + "tag": "0295_slimy_thunderbird", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/public_mcp.ts b/packages/db/src/schema/public_mcp.ts index 3b2adb2e84b..775395edae4 100644 --- a/packages/db/src/schema/public_mcp.ts +++ b/packages/db/src/schema/public_mcp.ts @@ -7,6 +7,7 @@ import { companies } from "./companies.js"; export const mcpOauthClients = pgTable("mcp_oauth_clients", { id: text("id").primaryKey(), name: text("name").notNull(), + registrationSourceHash: text("registration_source_hash"), redirectUris: jsonb("redirect_uris").$type().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 729b5f4af40..a4fd5ee854b 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -61,6 +61,7 @@ const apiPrefixes: Record = { "plugin-ui-static.ts": "/api", "plugins.ts": "/api", "projects.ts": "/api", + "public-mcp.ts": "/api", "project-tools.ts": "/api", "resource-memberships.ts": "/api", "remote-agent-profiles.ts": "/api", @@ -92,6 +93,13 @@ const HTTP_METHODS = new Set([ const explicitOpenApiCoverageExclusions = new Set(); const explicitOpenApiOperationCoverageExclusions = new Set([ + // OAuth discovery and protocol endpoints have their own metadata contract; + // browser connection-management operations remain documented in the board API. + "GET /.well-known/oauth-authorization-server", + "POST /mcp/oauth/register", + "GET /mcp/oauth/authorize", + "POST /mcp/oauth/token", + "POST /mcp/oauth/revoke", // This endpoint is authenticated by the provider signature rather than by a // Paperclip board/agent credential. It intentionally stays out of the public // board API document, while this exact exclusion keeps route coverage honest. @@ -132,6 +140,7 @@ function normalizeExpressPath(routePath: string) { } function resolveMountedPath(file: string, prefix: string, routePath: string) { + if (file === "public-mcp.ts" && (routePath.startsWith("/mcp/oauth/") || routePath.startsWith("/.well-known/"))) return routePath; if ( (file === "chat-channels.ts" || file === "email.ts") && routePath.startsWith("/api/chat-webhooks/") diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 2654b6a870b..48998224404 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -3,7 +3,7 @@ import express, { type Request } from "express"; import request from "supertest"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { createDb, authUsers, companies, companyMemberships, mcpOauthTokens, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db"; +import { createDb, authUsers, companies, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db"; import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret } from "../services/public-mcp/oauth.js"; import { createMcpApiDispatch, createPublicMcpExecutor, publicMcpCapabilities } from "../services/public-mcp/capabilities.js"; import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../routes/public-mcp.js"; @@ -51,7 +51,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => const [company] = await db.insert(companies).values({ name: "Team", issuePrefix: "M" + randomBytes(4).toString("hex") }).returning(); const [membership] = await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: role, status: "active" }).returning(); const actor: Request["actor"] = { type: "board", source: "session", userId }; - const client = await oauth.register({ client_name: "Test client", redirect_uris: [redirectUri] }); + const client = await oauth.register({ client_name: "Test client", redirect_uris: [redirectUri] }, randomUUID()); const url = await oauth.authorize({ client_id: client.client_id, redirect_uri: redirectUri, resource: config.resource, scope: "paperclip:read paperclip:write offline_access", state: "state", response_type: "code", code_challenge: challenge, code_challenge_method: "S256" }); const id = url.split("/").at(-1)!; const consent = await oauth.consent(id, actor, { decision: "approve", companyId: company!.id, allowWrites: write }); @@ -268,7 +268,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => await db.insert(mcpOauthClients).values({ id: stale, name: "Never connected", redirectUris: [redirectUri], createdAt: old }); await db.update(mcpOauthClients).set({ createdAt: old }); const replica = createPublicMcpOAuth(db, config); - const registrations = await Promise.allSettled(Array.from({ length: 70 }, (_, index) => (index % 2 ? oauth : replica).register({ client_name: "Quota fixture", redirect_uris: [redirectUri] }))); + const registrations = await Promise.allSettled(Array.from({ length: 70 }, (_, index) => (index % 2 ? oauth : replica).register({ client_name: "Quota fixture", redirect_uris: [redirectUri] }, `quota-source-${index}`))); const accepted = registrations.filter((r): r is PromiseFulfilledResult>> => r.status === "fulfilled"); try { expect(accepted).toHaveLength(60); @@ -280,4 +280,33 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => } }); + it("limits one registration source without denying another source", async () => { + const source = randomUUID(); + const sourceHash = hashMcpSecret(config.resource + ":" + source); + const replica = createPublicMcpOAuth(db, config); + try { + const batch = await Promise.allSettled(Array.from({ length: 8 }, (_, i) => (i % 2 ? oauth : replica).register({ client_name: "Source quota", redirect_uris: [redirectUri] }, source))); + expect(batch.filter(r => r.status === "fulfilled")).toHaveLength(6); + await expect(replica.register({ client_name: "Other source", redirect_uris: [redirectUri] }, randomUUID())).resolves.toHaveProperty("client_id"); + for (let batch = 0; batch < 4; batch++) { + await db.update(mcpOauthClients).set({ createdAt: new Date(Date.now() - 2 * 60_000) }).where(eq(mcpOauthClients.registrationSourceHash, sourceHash)); + for (let i = 0; i < 6; i++) await replica.register({ client_name: "Source quota", redirect_uris: [redirectUri] }, source); + } + await db.update(mcpOauthClients).set({ createdAt: new Date(Date.now() - 2 * 60_000) }).where(eq(mcpOauthClients.registrationSourceHash, sourceHash)); + await expect(oauth.register({ client_name: "Source quota", redirect_uris: [redirectUri] }, source)).rejects.toMatchObject({ status: 429 }); + } finally { await db.delete(mcpOauthClients).where(eq(mcpOauthClients.registrationSourceHash, sourceHash)); } + }); + + it("bounds authorization starts across replicas and reclaims expired requests without another registration", async () => { + const client = await oauth.register({ client_name: "Request quota", redirect_uris: [redirectUri] }, randomUUID()); + const input = { client_id: client.client_id, redirect_uri: redirectUri, response_type: "code", resource: config.resource, code_challenge: challenge, code_challenge_method: "S256" }; + const replica = createPublicMcpOAuth(db, config); + const starts = await Promise.allSettled(Array.from({ length: 12 }, (_, i) => (i % 2 ? oauth : replica).authorize(input))); + expect(starts.filter(r => r.status === "fulfilled")).toHaveLength(10); + expect(starts.filter(r => r.status === "rejected").map(r => r.reason.status)).toEqual([429, 429]); + await db.update(mcpOauthRequests).set({ expiresAt: new Date(0) }).where(eq(mcpOauthRequests.clientId, client.client_id)); + await expect(replica.authorize(input)).resolves.toContain("/mcp-connect/"); + expect(await db.select().from(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, client.client_id))).toHaveLength(1); + }); + }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 829064f4468..4971f8282a0 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -264,6 +264,7 @@ import { revokeToolTrustRuleSchema, unbindToolProfileBindingSchema, importMcpJsonSchema, + mcpConsentSchema, toolPolicyTestRequestSchema, createToolMcpGatewaySchema, completeConnectionIntentSchema, @@ -1660,6 +1661,8 @@ function resolveOperationAuthLevel( path: string, ): OpenApiAuthLevel { const key = operationKey(method, path); + if (key === "GET /api/mcp/requests/{id}") return "public"; + if (path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board"; if (PUBLIC_OPERATIONS.has(key)) return "public"; if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run"; if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools"; @@ -11299,6 +11302,28 @@ registerCurrentRoute({ }, }); +registerCurrentRoute({ + method: "get", path: "/api/mcp/requests/{id}", tags: ["tool-gateway"], + summary: "Describe an assistant connection request and available sign-in options", + responses: { 200: r.ok(), 404: r.notFound }, +}); +registerCurrentRoute({ + method: "post", path: "/api/mcp/requests/{id}/consent", tags: ["tool-gateway"], + summary: "Approve or deny assistant access using a same-origin browser session", + body: mcpConsentSchema, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); +registerCurrentRoute({ + method: "get", path: "/api/mcp/connections", tags: ["tool-gateway"], + summary: "List the signed-in person's assistant connections", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); +registerCurrentRoute({ + method: "delete", path: "/api/mcp/connections/{id}", tags: ["tool-gateway"], + summary: "Revoke an assistant connection using a same-origin browser session", + responses: { 204: { description: "Connection revoked" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + registerCurrentRoute({ method: "get", path: "/api/tool-gateway/gateways/{gatewayId}/mcp", diff --git a/server/src/routes/public-mcp.ts b/server/src/routes/public-mcp.ts index 4ef435682c8..17cbdf8ebaa 100644 --- a/server/src/routes/public-mcp.ts +++ b/server/src/routes/public-mcp.ts @@ -55,7 +55,7 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp code_challenge_methods_supported: ["S256"], scopes_supported: PUBLIC_MCP_SCOPES, })); router.use("/mcp/oauth", authRateLimit(), express.urlencoded({ extended: false, limit: "16kb" })); - router.post("/mcp/oauth/register", async (req, res) => res.status(201).json(await oauth.register(req.body))); + router.post("/mcp/oauth/register", async (req, res) => res.status(201).json(await oauth.register(req.body, req.ip ?? req.socket.remoteAddress ?? "unknown"))); router.get("/mcp/oauth/authorize", async (req, res) => res.redirect(303, await oauth.authorize(req.query))); router.post("/mcp/oauth/token", async (req, res) => res.json(await oauth.token(req.body ?? {}))); router.post("/mcp/oauth/revoke", async (req, res) => { diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index 70a80ad1bb0..7af5873a80c 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -98,24 +98,26 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) { return { config, - async register(input: unknown) { + async register(input: unknown, source = "unknown") { const parsed = registrationSchema.safeParse(input); if (!parsed.success) throw new McpOAuthError("invalid_client_metadata", "Supply a client name, valid redirect URIs, and public-client PKCE authentication."); - const client = { id: secret("pcmcp_client_"), name: parsed.data.client_name, redirectUris: parsed.data.redirect_uris }; + const client = { id: secret("pcmcp_client_"), name: parsed.data.client_name, registrationSourceHash: hashMcpSecret(config.resource + ":" + source), redirectUris: parsed.data.redirect_uris }; await db.transaction(async (tx) => { // Bound public DCR across replicas, not only per-IP in each process. await tx.execute(sql`SELECT pg_advisory_xact_lock(736721042)`); const now = new Date(); await tx.delete(mcpOauthRequests).where(lt(mcpOauthRequests.expiresAt, now)); await tx.delete(mcpOauthClients).where(and( - lt(mcpOauthClients.createdAt, new Date(now.getTime() - 24 * 60 * minute)), + lt(mcpOauthClients.createdAt, new Date(now.getTime() - 60 * minute)), notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id))), notExists(tx.select({ id: mcpOauthRequests.id }).from(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, mcpOauthClients.id))), )); const [counts] = await tx.select({ total: sql`count(*)::int`, + sourceTotal: sql`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${client.registrationSourceHash}))::int`, + sourceRecent: sql`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${client.registrationSourceHash} AND ${mcpOauthClients.createdAt} > ${new Date(now.getTime() - minute).toISOString()}::timestamptz))::int`, recent: sql`(count(*) FILTER (WHERE ${mcpOauthClients.createdAt} > ${new Date(now.getTime() - minute).toISOString()}::timestamptz))::int`, - }).from(mcpOauthClients); - if (!counts || counts.total >= 10_000 || counts.recent >= 60) { + }).from(mcpOauthClients).where(notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id)))); + if (!counts || counts.total >= 10_000 || counts.recent >= 60 || counts.sourceTotal >= 30 || counts.sourceRecent >= 6) { throw new McpOAuthError("temporarily_unavailable", "Registration capacity reached. Retry later.", 429); } await tx.insert(mcpOauthClients).values(client); @@ -136,9 +138,20 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) { throw new McpOAuthError("invalid_scope", "Unsupported Paperclip scope."); } const id = secret("pcmcp_request_"); - await db.insert(mcpOauthRequests).values({ - id, clientId: client.id, redirectUri: p.redirect_uri, resource: p.resource, scopes, - state: p.state ?? null, challenge: p.code_challenge, expiresAt: new Date(Date.now() + 10 * minute), + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(736721042)`); + const now = new Date(); + await tx.delete(mcpOauthRequests).where(lt(mcpOauthRequests.expiresAt, now)); + const [counts] = await tx.select({ total: sql`count(*)::int`, + client: sql`(count(*) FILTER (WHERE ${mcpOauthRequests.clientId} = ${client.id}))::int`, + }).from(mcpOauthRequests); + if (!counts || counts.total >= 1000 || counts.client >= 10) { + throw new McpOAuthError("temporarily_unavailable", "Too many pending connection requests. Retry later.", 429); + } + await tx.insert(mcpOauthRequests).values({ + id, clientId: client.id, redirectUri: p.redirect_uri, resource: p.resource, scopes, + state: p.state ?? null, challenge: p.code_challenge, expiresAt: new Date(now.getTime() + 10 * minute), + }); }); return config.origin + "/mcp-connect/" + id; }, diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 16e75a4caa3..6d77e604d65 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1102,7 +1102,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ extraConfig: { engine: "cli", maxTurnsPerRun: 16 }, })), ], - definitionMetadata: { version: 4, grader: publicMcpGraderVersion, workflowDigest: publicMcpWorkflowDigest, workerSkillDigest: publicMcpWorkerSkillDigest, workerInstructions: publicMcpWorkerInstructions, assistantMaxRequests: 16, assistantMaxEstimatedUsd: 2, instructions: "shipped-plugin-skills", scheduling: "explicit-only" }, + definitionMetadata: { version: 5, grader: publicMcpGraderVersion, workflowDigest: publicMcpWorkflowDigest, workerSkillDigest: publicMcpWorkerSkillDigest, workerInstructions: publicMcpWorkerInstructions, assistantMaxRequests: 16, assistantMaxEstimatedUsd: 2, instructions: "shipped-plugin-skills", scheduling: "explicit-only" }, }, { id: "blocker-guidance", label: "Direct blocker handling", manualOnly: true, diff --git a/tests/runner-e2e/public-mcp-model.test.ts b/tests/runner-e2e/public-mcp-model.test.ts index b06237d2741..788918a83a6 100644 --- a/tests/runner-e2e/public-mcp-model.test.ts +++ b/tests/runner-e2e/public-mcp-model.test.ts @@ -69,4 +69,15 @@ describe("external assistant evidence and billing", () => { expect(snapshots).toEqual([{ calls: 0, requests: 1 }, { calls: 1, requests: 1 }, { calls: 1, requests: 1 }]); expect(usage.estimatedCostUsd).toBeGreaterThan(0); }); + it("shares the 16-request ceiling across fresh conversations in one cell", async () => { + const fetchMock = vi.fn().mockImplementation(async () => Response.json({ model: "gpt-5.4-mini", usage: { input_tokens: 10, output_tokens: 10 }, output: [{ type: "message", content: [{ type: "output_text", text: "Done." }] }] })); + vi.stubGlobal("fetch", fetchMock); + const usage = assistantUsage("openai", "gpt-5.4-mini"); + const input = { usage, credential: "unit-test-key", prompt: "Fresh conversation", tools: [], call: vi.fn(), deadlineAt: Date.now() + 10_000, observe: vi.fn() }; + for (let i = 0; i < 16; i++) await runAssistant(input); + await expect(runAssistant(input)).rejects.toThrow("shared 16-request per-cell budget"); + expect(fetchMock).toHaveBeenCalledTimes(16); + expect(usage.requests).toBe(16); + }); + }); diff --git a/tests/runner-e2e/public-mcp-model.ts b/tests/runner-e2e/public-mcp-model.ts index da1da83fc0e..ffcd37611f4 100644 --- a/tests/runner-e2e/public-mcp-model.ts +++ b/tests/runner-e2e/public-mcp-model.ts @@ -50,6 +50,7 @@ export async function runAssistant(input: { const responseItems: any[] = [{ role: "user", content: input.prompt }]; try { for (let step = 0; step < 16; step++) { + if (input.usage.requests >= 16) throw new Error("Assistant exceeded its shared 16-request per-cell budget"); if (Date.now() >= input.deadlineAt) throw new Error("Assistant workflow exceeded its bounded deadline"); const anthropic = input.usage.provider === "anthropic"; const response = await fetch(anthropic ? "https://api.anthropic.com/v1/messages" : "https://api.openai.com/v1/responses", {