From 147ca2a9e0891a13c86820027f89626057eec7df Mon Sep 17 00:00:00 2001 From: Maverick-666 <1239916371@qq.com> Date: Wed, 1 Apr 2026 09:37:05 +0800 Subject: [PATCH 1/3] fix(client): clear stale session on HTTP 404 in streamable transport # Conflicts: # packages/client/src/client/streamableHttp.ts --- packages/client/src/client/streamableHttp.ts | 8 ++ .../client/test/client/streamableHttp.test.ts | 91 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 56cbb4d98..109a5aa16 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -252,6 +252,10 @@ export class StreamableHTTPClientTransport implements Transport { }); if (!response.ok) { + if (response.status === 404 && this._sessionId !== undefined) { + this._sessionId = undefined; + } + if (response.status === 401 && this._authProvider) { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); @@ -557,6 +561,10 @@ export class StreamableHTTPClientTransport implements Transport { } if (!response.ok) { + if (response.status === 404 && this._sessionId !== undefined) { + this._sessionId = undefined; + } + if (response.status === 401 && this._authProvider) { // Store WWW-Authenticate params for interactive finishAuth() path if (response.headers.has('www-authenticate')) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 4a23e6db4..1517d9894 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -220,7 +220,7 @@ describe('StreamableHTTPClientTransport', () => { await expect(transport.terminateSession()).resolves.not.toThrow(); }); - it('should handle 404 response when session expires', async () => { + it('should preserve existing 404 behavior when request is not session-bound', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', @@ -248,6 +248,63 @@ describe('StreamableHTTPClientTransport', () => { expect(errorSpy).toHaveBeenCalled(); }); + it('should clear session ID and mark 404 as recoverable for session-bound POST requests', async () => { + const initializeMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'test-id' + }; + + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers({ 'mcp-session-id': 'stale-session-id' }), + text: () => Promise.resolve('') + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('Session not found'), + headers: new Headers() + }) + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers(), + text: () => Promise.resolve('') + }); + + await transport.send(initializeMessage); + expect(transport.sessionId).toBe('stale-session-id'); + + await expect(transport.send(message)).rejects.toMatchObject({ + code: SdkErrorCode.ClientHttpNotImplemented, + message: 'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.', + data: expect.objectContaining({ + status: 404, + text: 'Session not found', + sessionExpired: true + }) + }); + expect(transport.sessionId).toBeUndefined(); + + await transport.send({ jsonrpc: '2.0', method: 'notifications/ping' } as JSONRPCMessage); + const lastCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect(lastCall[1].headers.get('mcp-session-id')).toBeNull(); + }); + it('should handle non-streaming JSON response', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0', @@ -309,6 +366,38 @@ describe('StreamableHTTPClientTransport', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); + it('should clear session ID when GET SSE stream returns 404 for a session-bound request', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'stale-session-id' + }); + await transport.start(); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('Session not found'), + headers: new Headers() + }); + + await expect( + (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}) + ).rejects.toMatchObject({ + code: SdkErrorCode.ClientHttpNotImplemented, + data: expect.objectContaining({ + status: 404, + text: 'Session not found', + sessionExpired: true + }) + }); + + expect(transport.sessionId).toBeUndefined(); + + const getCall = (globalThis.fetch as Mock).mock.calls[0]!; + expect(getCall[1].method).toBe('GET'); + expect(getCall[1].headers.get('mcp-session-id')).toBe('stale-session-id'); + }); + it('should handle successful initial GET connection for SSE', async () => { // Set up readable stream for SSE events const encoder = new TextEncoder(); From c9a745943231aaf9baf8e5cc4a716bd760eccd08 Mon Sep 17 00:00:00 2001 From: Maverick-666 <1239916371@qq.com> Date: Fri, 20 Mar 2026 10:56:42 +0800 Subject: [PATCH 2/3] chore(changeset): add patch release note for client 404 session recovery --- .changeset/gentle-maps-smile.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gentle-maps-smile.md diff --git a/.changeset/gentle-maps-smile.md b/.changeset/gentle-maps-smile.md new file mode 100644 index 000000000..9021c97d8 --- /dev/null +++ b/.changeset/gentle-maps-smile.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Clear stale Streamable HTTP client sessions when a session-bound request receives HTTP 404, and tag the thrown SDK error as recoverable (`sessionExpired: true`) so callers can reconnect and re-initialize. From 6078c5458b75ea4a4ba315a34fa294c6ff58329d Mon Sep 17 00:00:00 2001 From: Maverick-666 <1239916371@qq.com> Date: Wed, 1 Apr 2026 09:40:57 +0800 Subject: [PATCH 3/3] test(client): align 404 session reset assertions with minimal flow --- packages/client/test/client/streamableHttp.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 1517d9894..dc399f194 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -248,7 +248,7 @@ describe('StreamableHTTPClientTransport', () => { expect(errorSpy).toHaveBeenCalled(); }); - it('should clear session ID and mark 404 as recoverable for session-bound POST requests', async () => { + it('should clear session ID on 404 for session-bound POST requests', async () => { const initializeMessage: JSONRPCMessage = { jsonrpc: '2.0', method: 'initialize', @@ -291,11 +291,9 @@ describe('StreamableHTTPClientTransport', () => { await expect(transport.send(message)).rejects.toMatchObject({ code: SdkErrorCode.ClientHttpNotImplemented, - message: 'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.', data: expect.objectContaining({ status: 404, - text: 'Session not found', - sessionExpired: true + text: 'Session not found' }) }); expect(transport.sessionId).toBeUndefined(); @@ -383,11 +381,10 @@ describe('StreamableHTTPClientTransport', () => { await expect( (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}) ).rejects.toMatchObject({ - code: SdkErrorCode.ClientHttpNotImplemented, + code: SdkErrorCode.ClientHttpFailedToOpenStream, data: expect.objectContaining({ status: 404, - text: 'Session not found', - sessionExpired: true + statusText: 'Not Found' }) });