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:
parent
a49ec51e95
commit
819e33be07
@ -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");
|
||||
|
||||
@ -97,6 +97,24 @@ function handleOpenSeoMcpRequest(
|
||||
env: unknown,
|
||||
ctx: ExecutionContext,
|
||||
): 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 handler = createMcpHandler(server, {
|
||||
route: MCP_ROUTE,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user