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. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index dab9b37ab..7e0934bd3 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -207,6 +207,18 @@ export class StreamableHTTPClientTransport implements Transport { }); } + private _sessionExpiredError(text: string | null): SdkError { + return new SdkError( + SdkErrorCode.ClientHttpNotImplemented, + 'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.', + { + status: 404, + text, + sessionExpired: true + } + ); + } + private async _startOrAuthSse(options: StartSSEOptions): Promise { const { resumptionToken } = options; @@ -214,6 +226,7 @@ export class StreamableHTTPClientTransport implements Transport { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it const headers = await this._commonHeaders(); + const sentSessionId = headers.has('mcp-session-id'); headers.set('Accept', 'text/event-stream'); // Include Last-Event-ID header for resumable streams if provided @@ -229,13 +242,18 @@ export class StreamableHTTPClientTransport implements Transport { }); if (!response.ok) { - await response.text?.().catch(() => {}); + const text = await response.text?.().catch(() => null); if (response.status === 401 && this._authProvider) { // Need to authenticate return await this._authThenStart(); } + if (response.status === 404 && sentSessionId) { + this._sessionId = undefined; + throw this._sessionExpiredError(text); + } + // 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) { @@ -472,6 +490,7 @@ export class StreamableHTTPClientTransport implements Transport { } const headers = await this._commonHeaders(); + const sentSessionId = headers.has('mcp-session-id'); headers.set('content-type', 'application/json'); headers.set('accept', 'application/json, text/event-stream'); @@ -494,6 +513,11 @@ export class StreamableHTTPClientTransport implements Transport { if (!response.ok) { const text = await response.text?.().catch(() => null); + if (response.status === 404 && sentSessionId) { + this._sessionId = undefined; + throw this._sessionExpiredError(text); + } + if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 0398964d3..747cad40f 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -193,7 +193,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', @@ -221,6 +221,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', @@ -282,6 +339,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();