From f4098474731da814202f3045ff9faa6b322e7fff Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:22:16 +0100 Subject: [PATCH] fix(mcp): cancel SSE response streams that fail the message-size limit (#11735) When an SSE event went over `maxMessageBytes`, `consumeSseStream()` threw the size-limit error, but its `finally` only released the reader lock. Releasing the lock does not cancel the response body. If the server kept the response open, for example with heartbeats, the failed request held its HTTP connection until the server ended it or the whole client was closed. The same applies to the GET notification stream, which uses the same function. On error, `consumeSseStream()` now cancels the reader before it rethrows and releases the lock. A failure in `cancel()` is ignored, so the caller still gets the original error. Testing: - New test in `packages/mcp/test/streamable-http.test.ts`: it feeds an oversized event through a stream that stays open and checks that the stream's `cancel` callback runs. It fails without the change and passes with it. - Full mcp package tests: 48/48 pass. `npm run check` is clean. - Reproduction over loopback: a local `node:http` server sends a 512-byte event, then a heartbeat every 20 ms, and the client reads it through `fetch` with `maxEventBytes: 128`, three times. - Before: none of the three server responses closed, and the server made 28 more heartbeat writes. - After: all three closed, with no further writes. Fixes #11729 Requested by @badlogic Co-authored-by: triagent --- .../mcp/src/transports/streamable-http.ts | 4 ++++ packages/mcp/test/streamable-http.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/mcp/src/transports/streamable-http.ts b/packages/mcp/src/transports/streamable-http.ts index 5ea76682e..b55cb2084 100644 --- a/packages/mcp/src/transports/streamable-http.ts +++ b/packages/mcp/src/transports/streamable-http.ts @@ -103,6 +103,10 @@ export async function consumeSseStream(stream: ReadableStream, optio buffered += decoder.decode(); if (buffered) processLine(buffered); dispatch(); + } catch (error) { + // Releasing the lock alone leaves the response open; the original error wins over a failed cancel. + await reader.cancel().catch(() => {}); + throw error; } finally { reader.releaseLock(); } diff --git a/packages/mcp/test/streamable-http.test.ts b/packages/mcp/test/streamable-http.test.ts index 837508e81..81ae1d33d 100644 --- a/packages/mcp/test/streamable-http.test.ts +++ b/packages/mcp/test/streamable-http.test.ts @@ -118,6 +118,25 @@ describe("consumeSseStream", () => { ); expect(sent).toBeLessThan(100); }); + + // Regression test for #11729: a failed stream must be cancelled, not left open. + it("cancels the stream when an event exceeds the limit", async () => { + const encoder = new TextEncoder(); + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + // The server keeps the response open after the oversized event. + controller.enqueue(encoder.encode(`data: ${"x".repeat(512)}\n\n`)); + }, + cancel() { + cancelled = true; + }, + }); + await expect(consumeSseStream(stream, { maxEventBytes: 128, onEvent: () => {} })).rejects.toThrow( + "MCP SSE event exceeds 128 bytes", + ); + expect(cancelled).toBe(true); + }); }); describe("StreamableHttpTransport", () => {