From 584fe90d8a0531264e20dee7484a3a51dd168b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Airosa?= Date: Mon, 19 Jan 2026 11:32:33 +0800 Subject: [PATCH 1/2] fix(client): handle HTTP 410 Gone for expired/stale MCP sessions When a server returns HTTP 410 Gone (indicating the session no longer exists), the client now automatically clears the stale session ID and retries the request. This enables seamless reconnection after server restarts or session timeouts. Previously, receiving a 410 would throw an error but the client would retain the invalid session ID, causing all subsequent requests to fail with the same 410 error - breaking the connection permanently until the client was restarted. Changes: - Add 410 handling in send() method for POST requests - Add 410 handling in _startOrAuthSse() for SSE GET requests - Clear _sessionId before retrying to allow server to assign a new session - Add comprehensive tests for both POST and GET 410 scenarios Co-Authored-By: Claude Opus 4.5 --- packages/client/src/client/streamableHttp.ts | 15 +++ .../client/test/client/streamableHttp.test.ts | 113 ++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0b98e5d7a..b45668efe 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -248,6 +248,13 @@ export class StreamableHTTPClientTransport implements Transport { return; } + // Handle 410 Gone - session expired or server restarted + // Clear the stale session ID and retry to get a new session + if (response.status === 410) { + this._sessionId = undefined; + return await this._startOrAuthSse({ resumptionToken: undefined }); + } + throw new StreamableHTTPError(response.status, `Failed to open SSE stream: ${response.statusText}`); } @@ -559,6 +566,14 @@ export class StreamableHTTPClientTransport implements Transport { } } + // Handle 410 Gone - session expired or server restarted + // Clear the stale session ID and retry the request to get a new session + if (response.status === 410) { + this._sessionId = undefined; + // Retry the request - server will assign a new session ID + return this.send(message); + } + throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 0c5d2dc01..1aac5d545 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1561,6 +1561,119 @@ describe('StreamableHTTPClientTransport', () => { }); }); + describe('410 Gone session expired handling', () => { + it('should clear session ID and retry on 410 during POST request', async () => { + // First, simulate getting a session ID + const initMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'old-session-id' }) + }); + + await transport.send(initMessage); + expect(transport.sessionId).toBe('old-session-id'); + + // Now send a request that gets 410 - server restarted and old session is gone + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + (global.fetch as Mock) + // First attempt returns 410 + .mockResolvedValueOnce({ + ok: false, + status: 410, + statusText: 'Gone', + text: () => Promise.resolve('Session expired or server restarted'), + headers: new Headers() + }) + // Retry succeeds with a new session ID + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json', 'mcp-session-id': 'new-session-id' }), + json: () => Promise.resolve({ jsonrpc: '2.0', result: { success: true }, id: 'test-id' }) + }); + + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + await transport.send(message); + + // Verify fetch was called twice (initial 410, then retry) + const calls = (global.fetch as Mock).mock.calls; + expect(calls.length).toBeGreaterThanOrEqual(3); // init + 410 + retry + + // Verify the retry worked and we got a new session ID + expect(transport.sessionId).toBe('new-session-id'); + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + result: { success: true }, + id: 'test-id' + }) + ); + }); + + it('should clear session ID and retry on 410 during SSE GET request', async () => { + // Set up transport with a stale session ID + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'stale-session-id' + }); + + expect(transport.sessionId).toBe('stale-session-id'); + + const fetchMock = global.fetch as Mock; + + // First GET attempt returns 410 + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 410, + statusText: 'Gone' + }); + + // Retry GET succeeds + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: new ReadableStream() + }); + + await transport.start(); + await transport['_startOrAuthSse']({}); + + // Verify fetch was called twice + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET'); + expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET'); + + // Verify session ID was cleared (it's undefined now, server can assign a new one) + expect(transport.sessionId).toBeUndefined(); + + // Verify first request had the stale session ID + const firstCallHeaders = fetchMock.mock.calls[0]![1]?.headers; + expect(firstCallHeaders?.get('mcp-session-id')).toBe('stale-session-id'); + + // Verify second request did NOT have a session ID + const secondCallHeaders = fetchMock.mock.calls[1]![1]?.headers; + expect(secondCallHeaders?.get('mcp-session-id')).toBeNull(); + }); + }); + describe('prevent infinite recursion when server returns 401 after successful auth', () => { it('should throw error when server returns 401 after successful auth', async () => { const message: JSONRPCMessage = { From 0adb0e3fb77b441dc9d008998dcb4f5c17260860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Airosa?= Date: Mon, 19 Jan 2026 11:41:20 +0800 Subject: [PATCH 2/2] chore: add changeset for 410 session handling fix --- .changeset/handle-410-session-expired.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/handle-410-session-expired.md diff --git a/.changeset/handle-410-session-expired.md b/.changeset/handle-410-session-expired.md new file mode 100644 index 000000000..4a14384b6 --- /dev/null +++ b/.changeset/handle-410-session-expired.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Handle HTTP 410 Gone response for expired/stale MCP sessions by clearing the session ID and automatically retrying the request