From 1593929b0ba3f829290e09e1b4f2cd6b86ab4d0e Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 15 Jan 2026 14:14:47 +0100 Subject: [PATCH 1/3] Fix streamable HTTP to handle error responses Handle both success (JSONRPCResultResponse) and error (JSONRPCErrorResponse) responses for: - Setting receivedResponse flag to prevent unnecessary reconnection - ID remapping during stream resumption Previously only success responses were handled, which could cause: 1. Unnecessary reconnection attempts after receiving an error response 2. Incorrect request/response correlation during stream resumption This aligns with the Python SDK and Swift SDK implementations. --- packages/client/src/client/streamableHttp.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0b98e5d7a..7c5a1925a 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -4,6 +4,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol import { createFetchWithInit, isInitializedNotification, + isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, @@ -361,7 +362,8 @@ export class StreamableHTTPClientTransport implements Transport { if (!event.event || event.event === 'message') { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - if (isJSONRPCResultResponse(message)) { + // Handle both success AND error responses for completion detection and ID remapping + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { // Mark that we received a response - no need to reconnect for this request receivedResponse = true; if (replayMessageId !== undefined) { From 48d5606499ae27cd386af8987e30f2dd1f8fad13 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 15 Jan 2026 14:30:20 +0100 Subject: [PATCH 2/3] Add test for error response handling in SSE stream Verify that error responses (not just success responses) also: - Set receivedResponse flag to prevent reconnection - Get delivered to the message handler correctly --- .../client/test/client/streamableHttp.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 0c5d2dc01..1f5875402 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -944,6 +944,78 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); + it('should NOT reconnect a POST stream when error response was received', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + // Create a stream that sends: + // 1. Priming event with ID (enables potential reconnection) + // 2. An error response (should also prevent reconnection, just like success) + // 3. Then closes + const streamWithErrorResponse = new ReadableStream({ + start(controller) { + // Priming event with ID + controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n')); + // An error response to the request (tool not found, for example) + controller.enqueue( + new TextEncoder().encode( + 'id: error-456\ndata: {"jsonrpc":"2.0","error":{"code":-32602,"message":"Tool not found"},"id":"request-1"}\n\n' + ) + ); + // Stream closes normally + controller.close(); + } + }); + + const fetchMock = global.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithErrorResponse + }); + + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'request-1', + params: { name: 'nonexistent-tool' } + }; + + // ACT + await transport.start(); + await transport.send(requestMessage); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT + // THE KEY ASSERTION: Fetch was called ONCE only - no reconnection! + // The error response was received, so no need to reconnect. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); + + // Verify the error response was delivered to the message handler + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32602, + message: 'Tool not found' + }), + id: 'request-1' + }) + ); + }); + it('should not attempt reconnection after close() is called', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { From 6b4426a4c140ebb9dbd75a8f2937b49f73dbca90 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 15 Jan 2026 23:12:38 +0100 Subject: [PATCH 3/3] Add changeset --- .changeset/fix-streamable-http-error-response.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-streamable-http-error-response.md diff --git a/.changeset/fix-streamable-http-error-response.md b/.changeset/fix-streamable-http-error-response.md new file mode 100644 index 000000000..1de5839d3 --- /dev/null +++ b/.changeset/fix-streamable-http-error-response.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix StreamableHTTPClientTransport to handle error responses in SSE streams