diff --git a/.changeset/graceful-get-sse-404-406.md b/.changeset/graceful-get-sse-404-406.md new file mode 100644 index 000000000..75425eec6 --- /dev/null +++ b/.changeset/graceful-get-sse-404-406.md @@ -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. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3d45b60e9..4f1d9bd28 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -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; } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 55bf79a50..d61328b1c 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -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();