From 29d9e2ccf5a71c4c86c259440ccf6dd0c33049f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 05:16:29 +0000 Subject: [PATCH 1/3] test(node): read SSE streams until expected events arrive instead of assuming one fetch chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests in packages/middleware/node/test/streamableHttp.test.ts called reader.read() once and asserted that multiple SSE events were present in that single chunk. fetch makes no such guarantee: on Node 26.7 the events arrive in separate reads, so the tests fail while the middleware behaves correctly. Adds a readSSEUntil(response, predicate, timeoutMs) helper that accumulates decoded chunks until the predicate is satisfied, the stream ends, or the timeout cancels the reader — the same pattern the neighboring "multiple notifications while disconnected" test already uses — and applies it to the three single-read sites in the two affected tests. Fixes #2661 Approach credit: jstar0, who reported the failure and prepared an equivalent branch but could not open a PR. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GLGiVsm3WDL3j3Dnepy7Ci --- .../node/test/streamableHttp.test.ts | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index 140717c6bb..ef12fe6464 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -104,6 +104,39 @@ async function readSSEEvent(response: Response): Promise { return new TextDecoder().decode(value); } +/** + * Helper to read an SSE response stream until the accumulated text satisfies + * a predicate, the stream ends, or a timeout elapses. Returns whatever text + * was accumulated. + * + * fetch makes no guarantee that SSE events written separately by the server + * arrive in a single chunk — Node 26.7 delivers them in separate reads — so + * a test expecting more than one event must never assert on the result of a + * single read(). See #2661. + * + * The reader's lock is released on return, so the caller can still cancel + * the stream via `response.body.cancel()`. + */ +async function readSSEUntil(response: Response, predicate: (text: string) => boolean, timeoutMs = 2000): Promise { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let text = ''; + const timeout = setTimeout(() => void reader.cancel(), timeoutMs); + try { + while (!predicate(text)) { + const { value, done } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value, { stream: true }); + } + } finally { + clearTimeout(timeout); + reader.releaseLock(); + } + return text; +} + /** * Helper to send JSON-RPC request */ @@ -725,11 +758,9 @@ describe('Zod v4', () => { expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('text/event-stream'); - const reader = response.body?.getReader(); - - // The responses may come in any order or together in one chunk - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + // The responses may come in any order, and each may arrive in its + // own chunk — read until both are in. + const text = await readSSEUntil(response, t => t.includes('"id":"req-1"') && t.includes('"id":"req-2"')); // Check that both responses were sent on the same stream expect(text).toContain('"id":"req-1"'); @@ -1480,10 +1511,9 @@ describe('Zod v4', () => { // Send a server notification through the MCP server await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); - // Read the notification from the SSE stream - const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + // Read the notification from the SSE stream (the priming event and + // the notification may arrive in separate chunks) + const text = await readSSEUntil(sseResponse, t => t.includes('First notification from MCP server')); // Verify the notification was sent with an event ID expect(text).toContain('id: '); @@ -1498,7 +1528,7 @@ describe('Zod v4', () => { await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); // Close the first SSE stream to simulate a disconnect - await reader!.cancel(); + await sseResponse.body!.cancel(); // Reconnect with the Last-Event-ID to get missed messages const reconnectResponse = await fetch(baseUrl, { @@ -1513,10 +1543,9 @@ describe('Zod v4', () => { expect(reconnectResponse.status).toBe(200); - // Read the replayed notification - const reconnectReader = reconnectResponse.body?.getReader(); - const reconnectData = await reconnectReader!.read(); - const reconnectText = new TextDecoder().decode(reconnectData.value); + // Read the replayed notification (replayed events may span + // multiple chunks) + const reconnectText = await readSSEUntil(reconnectResponse, t => t.includes('Second notification from MCP server')); // Verify we received the second notification that was sent after our stored eventId expect(reconnectText).toContain('Second notification from MCP server'); From 30a4cc6a878b2ca33595238f4e9f573cc8795898 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 05:28:22 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(node):=20correct=20comment=20=E2=80=94?= =?UTF-8?q?=20the=20standalone=20GET=20stream=20writes=20no=20priming=20ev?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit: the chunk-splitting note misattributed the split to a priming event, but writePrimingEvent's only call site is the POST handler path. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GLGiVsm3WDL3j3Dnepy7Ci --- packages/middleware/node/test/streamableHttp.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index ef12fe6464..02be11b742 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -1511,8 +1511,8 @@ describe('Zod v4', () => { // Send a server notification through the MCP server await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); - // Read the notification from the SSE stream (the priming event and - // the notification may arrive in separate chunks) + // Read the notification from the SSE stream (it may arrive split + // across multiple chunks) const text = await readSSEUntil(sseResponse, t => t.includes('First notification from MCP server')); // Verify the notification was sent with an event ID From 3988a19b370b2b578c769e07db98494343ce8f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 05:40:38 +0000 Subject: [PATCH 3/3] test(node): fold remaining single-read and inline read-loop sites into readSSEUntil Review follow-up, three non-blocking nits in one sweep: - the pre-parsed batch test still read one chunk over two separately-written batch responses (the #2661 pattern) - the two surviving inline readWithTimeout closures now use the helper (one copy had drifted: fresh TextDecoder per chunk without { stream: true } and no releaseLock); the 5s bound is preserved via the timeoutMs parameter - readSSEEvent's doc note now points at readSSEUntil instead of suggesting manual multi-reads Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GLGiVsm3WDL3j3Dnepy7Ci --- .../node/test/streamableHttp.test.ts | 42 +++---------------- 1 file changed, 5 insertions(+), 37 deletions(-) diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index 02be11b742..88d6e64092 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -95,8 +95,8 @@ const TEST_MESSAGES = { /** * Helper to extract text from SSE response - * Note: Can only be called once per response stream. For multiple reads, - * get the reader manually and read multiple times. + * Note: Can only be called once per response stream. For multiple reads or + * when events may arrive across chunks, use readSSEUntil below. */ async function readSSEEvent(response: Response): Promise { const reader = response.body?.getReader(); @@ -1345,9 +1345,7 @@ describe('Zod v4', () => { expect(response.status).toBe(200); - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + const text = await readSSEUntil(response, t => t.includes('"id":"batch-1"')); expect(text).toContain('"id":"batch-1"'); expect(text).toContain('"tools"'); @@ -1599,24 +1597,8 @@ describe('Zod v4', () => { expect(reconnectResponse.status).toBe(200); - // Read replayed notifications with a timeout - const reconnectReader = reconnectResponse.body?.getReader(); - let allText = ''; - // Read chunks until we have all 3 notifications or timeout - const readWithTimeout = async () => { - const timeout = setTimeout(() => reconnectReader!.cancel(), 2000); - try { - while (!allText.includes('Missed notification 3')) { - const { value, done } = await reconnectReader!.read(); - if (done) break; - allText += new TextDecoder().decode(value); - } - } finally { - clearTimeout(timeout); - } - }; - await readWithTimeout(); + const allText = await readSSEUntil(reconnectResponse, t => t.includes('Missed notification 3')); // Verify we received ALL notifications that were sent while disconnected expect(allText).toContain('Missed notification 1'); @@ -2398,21 +2380,7 @@ describe('Zod v4', () => { expect(reconnectResponse.status).toBe(200); // Read the replayed notification - const reconnectReader = reconnectResponse.body?.getReader(); - let allText = ''; - const readWithTimeout = async () => { - const timeout = setTimeout(() => reconnectReader!.cancel(), 5000); - try { - while (!allText.includes('Missed while disconnected')) { - const { value, done } = await reconnectReader!.read(); - if (done) break; - allText += new TextDecoder().decode(value); - } - } finally { - clearTimeout(timeout); - } - }; - await readWithTimeout(); + const allText = await readSSEUntil(reconnectResponse, t => t.includes('Missed while disconnected'), 5000); // Verify we received the notification that was sent while disconnected expect(allText).toContain('Missed while disconnected');