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) {