From 819e33be070ac9a1745978097b58fbcb64e0b633 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:57:25 -0400 Subject: [PATCH] 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. --- src/server/mcp/transport.test.ts | 22 ++++++++++++++++++++++ src/server/mcp/transport.ts | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/server/mcp/transport.test.ts b/src/server/mcp/transport.test.ts index 23cf363..0b4aedc 100644 --- a/src/server/mcp/transport.test.ts +++ b/src/server/mcp/transport.test.ts @@ -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 () => { const { handleSelfHostedOpenSeoMcpRequest } = await import("@/server/mcp/transport"); diff --git a/src/server/mcp/transport.ts b/src/server/mcp/transport.ts index d0f963b..603b4c8 100644 --- a/src/server/mcp/transport.ts +++ b/src/server/mcp/transport.ts @@ -97,6 +97,24 @@ function handleOpenSeoMcpRequest( env: unknown, ctx: ExecutionContext, ): Promise { + // 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 handler = createMcpHandler(server, { route: MCP_ROUTE,