From 30e1d33df9e81ea76858f7cc300bc97a8f59bd28 Mon Sep 17 00:00:00 2001 From: 4444jPPP Date: Fri, 27 Mar 2026 18:10:03 -0400 Subject: [PATCH] fix(client): handle 404 and 406 gracefully in GET SSE stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the early-return guard in _startOrAuthSse() to include 404 and 406 alongside the existing 405 check. All three status codes indicate the server does not offer an SSE stream at the GET endpoint — the client falls back to POST-only communication. Fixes #1635 --- .changeset/graceful-get-sse-404-406.md | 5 ++ packages/client/src/client/streamableHttp.ts | 9 ++- .../client/test/client/streamableHttp.test.ts | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .changeset/graceful-get-sse-404-406.md 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();