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/graceful-get-sse-404-406.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Handle 404 and 406 responses gracefully in GET SSE stream initialization, matching existing 405 behavior. Servers that lack a GET handler (404) or reject `Accept: text/event-stream` (406) now fall back to POST-only communication instead of throwing a fatal error.
9 changes: 6 additions & 3 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,12 @@ export class StreamableHTTPClientTransport implements Transport {

await response.text?.().catch(() => {});

// 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) {
// These status codes indicate that the server does not offer an SSE stream at the GET endpoint.
// 404: server has no GET handler at this endpoint (only POST)
// 405: server explicitly rejects GET method
// 406: server rejects the Accept header for GET (e.g., does not serve text/event-stream)
// All are expected cases that should not trigger an error — the client falls back to POST.
if (response.status === 404 || response.status === 405 || response.status === 406) {
return;
}

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

it('should handle 404 gracefully when server has no GET handler', async () => {
// Mock the server having no GET handler at this endpoint (only POST)
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found'
});

await transport.start();
await expect(transport['_startOrAuthSse']({})).resolves.not.toThrow();
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 handle 406 gracefully when server rejects Accept header for GET', async () => {
// Mock the server rejecting the Accept: text/event-stream header
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 406,
statusText: 'Not Acceptable'
});

await transport.start();
await expect(transport['_startOrAuthSse']({})).resolves.not.toThrow();
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