Merge branch 'fix/v1436-locks' into release/v1.43.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-19 20:20:28 +02:00
commit 1fef2c81d0
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) {