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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-19 18:34:37 +02:00
1 parent 1606099005
commit 3bdd53ab76
2 files changed
+47 -13

No files matched your search

+29 -13
View File
@@ -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();
});
});
+18
View File
@@ -181,6 +181,24 @@ async function handleMcp(request: Request): Promise<Response> {
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 `<userId>:<tokenId>` credential binding.
const rl = await checkMcpRateLimit(ctx.binding);
if (!rl.allowed) {