diff --git a/doc/public-mcp.md b/doc/public-mcp.md index 92d803fbd6..4618e4d1e5 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -172,6 +172,6 @@ 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 +remove expired requests and enforce 10 pending-consent 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/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 4899822440..9e96e9dfa4 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -309,4 +309,16 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => expect(await db.select().from(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, client.client_id))).toHaveLength(1); }); + it("does not charge completed connections against a shared client's pending consent quota", async () => { + const f = await fixture(); + const input = { client_id: f.client.client_id, redirect_uri: redirectUri, response_type: "code", resource: config.resource, code_challenge: challenge, code_challenge_method: "S256" }; + for (let i = 0; i < 12; i++) { + const id = (await oauth.authorize(input)).split("/").at(-1)!; + const consent = await oauth.consent(id, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: false }); + const code = new URL(consent.redirectUrl).searchParams.get("code")!; + await expect(oauth.token({ grant_type: "authorization_code", client_id: f.client.client_id, redirect_uri: redirectUri, resource: config.resource, code, code_verifier: verifier })).resolves.toHaveProperty("access_token"); + } + await expect(oauth.authorize(input)).resolves.toContain("/mcp-connect/"); + }); + }); diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index 7af5873a80..097f3bdbdf 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -144,7 +144,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) { 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); + }).from(mcpOauthRequests).where(isNull(mcpOauthRequests.decidedAt)); if (!counts || counts.total >= 1000 || counts.client >= 10) { throw new McpOAuthError("temporarily_unavailable", "Too many pending connection requests. Retry later.", 429); }