From 3bdd53ab76d6bc3bf8d53f1702fa6fee6da8233d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 18:34:37 +0200 Subject: [PATCH] fix(mcp): answer GET and DELETE with 405 so clients stop reconnecting The route is stateless and returns one buffered JSON response per call, so it opens no server stream and holds no session. GET let the transport open an event stream that the handler closed again at once; a client read that as a dropped connection and reconnected about once a second, each attempt costing a rate-limit slot and a module lookup. GET and DELETE now answer 405 with Allow: POST after authentication, which is what the transport specification asks of a server without that stream, and an unauthenticated GET still gets the 401 challenge. --- src/app/mcp/__tests__/route.test.ts | 42 ++++++++++++++++++++--------- src/app/mcp/route.ts | 18 +++++++++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 0424c7276..d5342866c 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -77,7 +77,7 @@ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: headersGet })), })); -import { POST, GET } from "../route"; +import { POST, GET, DELETE } from "../route"; import { resolveBearerToken } from "@/lib/auth/bearer"; import { isModuleEnabled } from "@/lib/modules/gate"; import { checkMcpRateLimit } from "@/lib/rate-limit"; @@ -428,24 +428,40 @@ describe("/mcp — write tools gate on health:write", () => { }); }); -describe("/mcp — GET transport", () => { - it("is wired for GET (SSE stream open) behind the same gates", async () => { +describe("/mcp — GET and DELETE", () => { + const bearer = { authorization: "Bearer hlk_" + "a".repeat(64) }; + + it("answers an authenticated GET with 405 and Allow: POST, without spending a rate-limit slot", async () => { validToken(); const res = await GET( new Request("http://localhost/mcp", { method: "GET", - headers: { - authorization: "Bearer hlk_" + "a".repeat(64), - accept: "text/event-stream", - }, + headers: { ...bearer, accept: "text/event-stream" }, }), ); - // Auth + gates ran; the transport answers (a stream open or a - // method-specific status), never a 401/404/429 on the happy path. - expect([200, 405]).toContain(res.status); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("POST"); expect(resolveBearerToken).toHaveBeenCalledTimes(1); - expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp"); - // Release any opened stream so the test process can exit cleanly. - await res.body?.cancel().catch(() => {}); + expect(isModuleEnabled).not.toHaveBeenCalled(); + }); + + it("answers DELETE the same way", async () => { + validToken(); + const res = await DELETE( + new Request("http://localhost/mcp", { + method: "DELETE", + headers: bearer, + }), + ); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("POST"); + }); + + it("still challenges an unauthenticated GET with 401, so discovery works", async () => { + const res = await GET( + new Request("http://localhost/mcp", { method: "GET" }), + ); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBeTruthy(); }); }); diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 30b4b1738..126d9238d 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -181,6 +181,24 @@ async function handleMcp(request: Request): Promise { return unauthorized(request, SCOPE_HEALTH_READ); } + // The server is stateless and answers every call as one buffered JSON + // response, so it offers no server-to-client stream and no session to + // end. The transport specification answers GET and DELETE with 405 and + // `Allow: POST` in that case, and a client then stops asking. Letting + // the SDK open an event stream here closed it again straight away; the + // client read that as a dropped connection and reconnected about once a + // second, each attempt spending a rate-limit slot and a module lookup. + if (request.method !== "POST") { + annotate({ + action: { name: "mcp.method.not_allowed" }, + meta: { method: request.method }, + }); + return Response.json( + { error: "method_not_allowed" }, + { status: 405, headers: { Allow: "POST" } }, + ); + } + // 2. RATE LIMIT — per `:` credential binding. const rl = await checkMcpRateLimit(ctx.binding); if (!rl.allowed) {