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/fix-streamable-http-404-session-clear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Clear stale session ID on HTTP 404 in StreamableHTTPClientTransport per MCP spec §Session Management
14 changes: 14 additions & 0 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> })._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',
Expand Down
Loading