From 7aa37fd3b821b503efd5a9cdd075f33bf0ac60fb Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:26:13 +0200 Subject: [PATCH] fix(mcp): return 405 (not 401) on GET /mcp to stop client re-auth storm (#747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Streamable HTTP GET handler returned 401 unconditionally. This server is stateless and offers no server-initiated SSE stream, for which the MCP Streamable HTTP spec requires 405 Method Not Allowed. Returning 401 made spec-compliant clients (Claude connector, Claude Desktop, Cursor) treat the SSE GET as an auth failure and enter a refresh-token → re-open-GET → 401 retry loop. Across the active connector base this storms /api/extensions/ext/mcp-server/mcp (observed ~steady GET→401 traffic on app.gnubok.se) and churns OAuth API-key rotation — and tripped a Vercel usage anomaly (edge requests + function invocations spiking ~16x). OAuth discovery remains bootstrapped on the POST 401 (WWW-Authenticate + .well-known/oauth-protected-resource); the POST JSON-RPC channel and the POST-only npm bridge are unaffected. Co-authored-by: Claude Opus 4.8 (1M context) --- extensions/general/mcp-server/index.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/extensions/general/mcp-server/index.ts b/extensions/general/mcp-server/index.ts index f42d2816..542be1e8 100644 --- a/extensions/general/mcp-server/index.ts +++ b/extensions/general/mcp-server/index.ts @@ -36,14 +36,18 @@ export const mcpServerExtension: Extension = { method: 'GET', path: '/mcp', skipAuth: true, + // This server is stateless and offers no server-initiated SSE stream, so + // the Streamable HTTP spec requires 405 Method Not Allowed here. Returning + // 401 (as we previously did) makes spec-compliant clients (Claude + // connector, Claude Desktop, Cursor) treat the SSE GET as an auth failure + // and retry-loop — refresh token → re-open GET → 401 → … — which storms + // the endpoint and churns OAuth key rotation. OAuth discovery is + // bootstrapped on the POST 401 (WWW-Authenticate), not here. handler: async (request: Request) => { if (isForbiddenOrigin(request)) return forbiddenOriginResponse() - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - return new Response('Authorization required', { - status: 401, - headers: { - 'WWW-Authenticate': `Bearer resource_metadata="${appUrl}/.well-known/oauth-protected-resource"`, - }, + return new Response('Method Not Allowed', { + status: 405, + headers: { Allow: 'POST, DELETE' }, }) }, },