fix(mcp): 405 the standalone GET SSE stream to stop /mcp OOM (#325)

The stateless MCP server returns JSON on POST (enableJsonResponse) and
pushes no server-initiated messages, so the optional standalone GET SSE
stream serves no purpose. Left enabled, each GET holds an SSE stream open
indefinitely (25s keepalive, no eventStore) and pins a fresh per-request
McpServer (~5MB of tools + Zod schemas); a few dozen concurrent connected
clients exceed the 128MB isolate limit. This was 100% of the /mcp
exceededMemory OOMs (GET only; POST never OOMed).

Return 405 (spec-compliant 'no standalone stream') before building the
server, so GET allocates nothing. Also removes the bulk of the elevated
GET canceled / responseStreamDisconnected outcomes.
This commit is contained in:
Ben Senescu 2026-06-30 21:57:25 -04:00 committed by Ben Senescu
parent a49ec51e95
commit 819e33be07
2 changed files with 40 additions and 0 deletions

View File

@ -160,6 +160,28 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
}); });
}); });
// The OOM came from the GET SSE stream pinning a per-request McpServer, so
// GET must 405 without ever building one.
it("returns 405 for the standalone GET SSE stream without building a server", async () => {
const { handleSelfHostedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
const response = await handleSelfHostedOpenSeoMcpRequest(
new Request("https://open-seo.test/mcp", {
method: "GET",
headers: { Accept: "text/event-stream" },
}),
"local_noauth",
{},
ctx,
);
expect(response.status).toBe(405);
expect(response.headers.get("Allow")).toContain("POST");
// nextServerId only advances when a server is built — a GET must not.
expect(serverMocks.nextServerId).toBe(0);
});
it("lets the MCP transport handle OPTIONS without auth context", async () => { it("lets the MCP transport handle OPTIONS without auth context", async () => {
const { handleSelfHostedOpenSeoMcpRequest } = const { handleSelfHostedOpenSeoMcpRequest } =
await import("@/server/mcp/transport"); await import("@/server/mcp/transport");

View File

@ -97,6 +97,24 @@ function handleOpenSeoMcpRequest(
env: unknown, env: unknown,
ctx: ExecutionContext, ctx: ExecutionContext,
): Promise<Response> { ): Promise<Response> {
// Decline the optional standalone GET SSE stream: this server is stateless
// (POST returns JSON) and pushes no server-initiated messages, so the stream
// does nothing but leak memory — each GET is held open by a keepalive and
// pins a per-request McpServer (~5MB), so a few dozen concurrent clients OOM
// the 128MB isolate. 405 is the spec's "no stream" response; returning it
// before building the server means a GET allocates nothing.
if (request.method === "GET") {
return Promise.resolve(
new Response("Method Not Allowed", {
status: 405,
headers: {
Allow: "POST, DELETE, OPTIONS",
"Access-Control-Allow-Origin": "*",
},
}),
);
}
const server = createOpenSeoMcpServer(); const server = createOpenSeoMcpServer();
const handler = createMcpHandler(server, { const handler = createMcpHandler(server, {
route: MCP_ROUTE, route: MCP_ROUTE,