diff --git a/.changeset/fix-streamable-http-404-session-clear.md b/.changeset/fix-streamable-http-404-session-clear.md new file mode 100644 index 0000000000..88e5e8fb68 --- /dev/null +++ b/.changeset/fix-streamable-http-404-session-clear.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Clear stale session ID on HTTP 404 in StreamableHTTPClientTransport per MCP spec §Session Management diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cd643c96dc..675a799f03 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -288,6 +288,13 @@ export class StreamableHTTPClientTransport implements Transport { return; } + // Per MCP spec §Session Management point 4: when a client receives HTTP 404 + // in response to a request containing an Mcp-Session-Id, it MUST start a new + // session. Clear the stale session ID so the next send goes without it. + if (response.status === 404 && this._sessionId) { + this._sessionId = undefined; + } + throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { status: response.status, statusText: response.statusText @@ -629,6 +636,13 @@ export class StreamableHTTPClientTransport implements Transport { } } + // Per MCP spec §Session Management point 4: when a client receives HTTP 404 + // in response to a request containing an Mcp-Session-Id, it MUST start a new + // session. Clear the stale session ID so the next send goes without it. + if (response.status === 404 && this._sessionId) { + this._sessionId = undefined; + } + throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { status: response.status, text diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index b2138b3fa8..8cb5ffbfd4 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -248,6 +248,94 @@ describe('StreamableHTTPClientTransport', () => { expect(errorSpy).toHaveBeenCalled(); }); + it('should clear session ID on 404 POST (per spec §Session Management)', async () => { + // Simulate a transport that already has an established session + const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'stale-session-id' + }); + expect(sessionTransport.sessionId).toBe('stale-session-id'); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'test-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('Session not found'), + headers: new Headers() + }); + + sessionTransport.onerror = vi.fn(); + await expect(sessionTransport.send(message)).rejects.toThrow(); + + // Per spec, session ID MUST be cleared on 404 so the next request goes without it + expect(sessionTransport.sessionId).toBeUndefined(); + + await sessionTransport.close().catch(() => {}); + }); + + it('should clear session ID on 404 GET SSE (per spec §Session Management)', async () => { + // Simulate a transport that already has an established session + const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'stale-session-id' + }); + expect(sessionTransport.sessionId).toBe('stale-session-id'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers() + }); + + sessionTransport.onerror = vi.fn(); + await transport.start(); + await expect( + (sessionTransport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}) + ).rejects.toThrow(); + + // Per spec, session ID MUST be cleared on 404 so the next request goes without it + expect(sessionTransport.sessionId).toBeUndefined(); + + await sessionTransport.close().catch(() => {}); + }); + + it('should NOT clear session ID on non-404 errors', async () => { + // Simulate a transport that already has an established session + const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'active-session-id' + }); + expect(sessionTransport.sessionId).toBe('active-session-id'); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'test-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve('Server error'), + headers: new Headers() + }); + + sessionTransport.onerror = vi.fn(); + await expect(sessionTransport.send(message)).rejects.toThrow(); + + // Session ID should NOT be cleared for non-404 errors + expect(sessionTransport.sessionId).toBe('active-session-id'); + + await sessionTransport.close().catch(() => {}); + }); + it('should handle non-streaming JSON response', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0',