Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-double-onerror-startOrAuthSse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Fix double `onerror` invocation when `_startOrAuthSse` fails. The internal catch block fired `onerror` then threw, and all callers already `.catch(onerror)`, causing every failure to fire twice. Removed the redundant internal call.
3 changes: 1 addition & 2 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,7 @@ export class StreamableHTTPClientTransport implements Transport {

this._handleSseStream(response.body, options, true);
} catch (error) {
this.onerror?.(error as Error);
throw error;
throw error as Error;
}
}

Expand Down
37 changes: 37 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,43 @@ describe('StreamableHTTPClientTransport', () => {
expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST');
});

it('should fire onerror exactly once when _startOrAuthSse fails (not double-fire from catch + caller)', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));

const errorSpy = vi.fn();
transport.onerror = errorSpy;

const fetchMock = globalThis.fetch as Mock;

// POST returns 202, which triggers _startOrAuthSse when the outbound
// message is an initialized notification (streamableHttp.ts:642)
fetchMock.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers(),
text: async () => ''
});

// The subsequent GET (_startOrAuthSse) fails with a non-ok status
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
headers: new Headers(),
text: async () => 'server error'
});

await transport.start();
// Sending an initialized notification triggers the _startOrAuthSse path
await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' });

// Let the fire-and-forget _startOrAuthSse().catch() settle
await vi.runAllTimersAsync();

expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0]![0].message).toContain('Failed to open SSE stream');
});

it('should not throw JSON parse error on priming events with empty data', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));

Expand Down
Loading