Skip to content

Commit 9bc9abc

Browse files
Fix: Handle error responses in Streamable HTTP SSE streams (modelcontextprotocol#1390)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent fcde488 commit 9bc9abc

File tree

3 files changed

+80
-1
lines changed

3 files changed

+80
-1
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Fix StreamableHTTPClientTransport to handle error responses in SSE streams

packages/client/src/client/streamableHttp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol
44
import {
55
createFetchWithInit,
66
isInitializedNotification,
7+
isJSONRPCErrorResponse,
78
isJSONRPCRequest,
89
isJSONRPCResultResponse,
910
JSONRPCMessageSchema,
@@ -412,7 +413,8 @@ export class StreamableHTTPClientTransport implements Transport {
412413
if (!event.event || event.event === 'message') {
413414
try {
414415
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
415-
if (isJSONRPCResultResponse(message)) {
416+
// Handle both success AND error responses for completion detection and ID remapping
417+
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
416418
// Mark that we received a response - no need to reconnect for this request
417419
receivedResponse = true;
418420
if (replayMessageId !== undefined) {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,78 @@ describe('StreamableHTTPClientTransport', () => {
10081008
expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST');
10091009
});
10101010

1011+
it('should NOT reconnect a POST stream when error response was received', async () => {
1012+
// ARRANGE
1013+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
1014+
reconnectionOptions: {
1015+
initialReconnectionDelay: 10,
1016+
maxRetries: 1,
1017+
maxReconnectionDelay: 1000,
1018+
reconnectionDelayGrowFactor: 1
1019+
}
1020+
});
1021+
1022+
const messageSpy = vi.fn();
1023+
transport.onmessage = messageSpy;
1024+
1025+
// Create a stream that sends:
1026+
// 1. Priming event with ID (enables potential reconnection)
1027+
// 2. An error response (should also prevent reconnection, just like success)
1028+
// 3. Then closes
1029+
const streamWithErrorResponse = new ReadableStream({
1030+
start(controller) {
1031+
// Priming event with ID
1032+
controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n'));
1033+
// An error response to the request (tool not found, for example)
1034+
controller.enqueue(
1035+
new TextEncoder().encode(
1036+
'id: error-456\ndata: {"jsonrpc":"2.0","error":{"code":-32602,"message":"Tool not found"},"id":"request-1"}\n\n'
1037+
)
1038+
);
1039+
// Stream closes normally
1040+
controller.close();
1041+
}
1042+
});
1043+
1044+
const fetchMock = global.fetch as Mock;
1045+
fetchMock.mockResolvedValueOnce({
1046+
ok: true,
1047+
status: 200,
1048+
headers: new Headers({ 'content-type': 'text/event-stream' }),
1049+
body: streamWithErrorResponse
1050+
});
1051+
1052+
const requestMessage: JSONRPCRequest = {
1053+
jsonrpc: '2.0',
1054+
method: 'tools/call',
1055+
id: 'request-1',
1056+
params: { name: 'nonexistent-tool' }
1057+
};
1058+
1059+
// ACT
1060+
await transport.start();
1061+
await transport.send(requestMessage);
1062+
await vi.advanceTimersByTimeAsync(50);
1063+
1064+
// ASSERT
1065+
// THE KEY ASSERTION: Fetch was called ONCE only - no reconnection!
1066+
// The error response was received, so no need to reconnect.
1067+
expect(fetchMock).toHaveBeenCalledTimes(1);
1068+
expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST');
1069+
1070+
// Verify the error response was delivered to the message handler
1071+
expect(messageSpy).toHaveBeenCalledWith(
1072+
expect.objectContaining({
1073+
jsonrpc: '2.0',
1074+
error: expect.objectContaining({
1075+
code: -32602,
1076+
message: 'Tool not found'
1077+
}),
1078+
id: 'request-1'
1079+
})
1080+
);
1081+
});
1082+
10111083
it('should not attempt reconnection after close() is called', async () => {
10121084
// ARRANGE
10131085
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {

0 commit comments

Comments
 (0)