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/stale-mayflies-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Handle 404 and 406 status codes gracefully for GET SSE streams, matching existing 405 behavior
5 changes: 3 additions & 2 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ export class StreamableHTTPClientTransport implements Transport {
}

// 405 indicates that the server does not offer an SSE stream at GET endpoint
// This is an expected case that should not trigger an error
if (response.status === 405) {
// 404 indicates the endpoint does not exist, 406 indicates the content type is not accepted
// All three are expected cases that should not trigger an error
if (response.status === 405 || response.status === 404 || response.status === 406) {
return;
}

Expand Down
64 changes: 64 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,70 @@ describe('StreamableHTTPClientTransport', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it('should attempt initial GET connection and handle 404 gracefully', async () => {
// Mock the server returning 404 for the SSE GET endpoint (endpoint does not exist)
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found'
});

// We expect the 404 error to be caught and handled gracefully
// This should not throw an error that breaks the transport
await transport.start();
await expect(transport['_startOrAuthSse']({})).resolves.not.toThrow('Failed to open SSE stream: Not Found');
// Check that GET was attempted
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers)
})
);

// Verify transport still works after 404
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});

await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it('should attempt initial GET connection and handle 406 gracefully', async () => {
// Mock the server returning 406 for the SSE GET endpoint (content type not acceptable)
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 406,
statusText: 'Not Acceptable'
});

// We expect the 406 error to be caught and handled gracefully
// This should not throw an error that breaks the transport
await transport.start();
await expect(transport['_startOrAuthSse']({})).resolves.not.toThrow('Failed to open SSE stream: Not Acceptable');
// Check that GET was attempted
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers)
})
);

// Verify transport still works after 406
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});

await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it('should handle successful initial GET connection for SSE', async () => {
// Set up readable stream for SSE events
const encoder = new TextEncoder();
Expand Down
Loading