diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index 140717c6bb..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(); @@ -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"'); @@ -1314,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"'); @@ -1480,10 +1509,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 (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 expect(text).toContain('id: '); @@ -1498,7 +1526,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 +1541,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'); @@ -1570,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'); @@ -2369,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');