Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/handle-410-session-expired.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -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}`);
}

Expand Down
113 changes: 113 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading