diff --git a/.changeset/initialize-session-hygiene.md b/.changeset/initialize-session-hygiene.md new file mode 100644 index 0000000000..b12e3c514f --- /dev/null +++ b/.changeset/initialize-session-hygiene.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +The Streamable HTTP client transport no longer attaches a session ID to a POST containing an `initialize` request — a new session starts "without a session ID attached" (2025-11-25 transports §Session Management) — and it only captures the `mcp-session-id` response header from a successful initialize response, since the spec assigns the session ID "at initialization time … on the HTTP response containing the InitializeResult". Previously the transport stored the header from any response, so a legacy server answering a protocol-version probe with an error that happened to carry a session ID would poison the fallback initialize, which then went out with a session ID it should not have had. A stale session ID from a previous connection is likewise no longer leaked onto the initialize handshake, and a successful initialize response that carries no session ID now clears any stale ID the transport was holding — clients include only an ID "returned by the server during initialization", so an ID the server never returned this session is outside the session model. Ignoring `mcp-session-id` headers mid-session is the complement of the spec's one actual rotation mechanism: a server that wants a new session terminates the old one (it "MAY terminate the session at any time") and answers 404, after which the client "MUST start a new session by sending a new InitializeRequest without a session ID attached". Rotation exists as session replacement via 404 + re-initialize, never as a header swap on a live session, so a server that rotates per the spec's own flow is handled correctly by this transport. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0d8eff917b..9067fd1ec4 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -5,6 +5,7 @@ import { createFetchWithInit, encodeMcpParamValue, isInitializedNotification, + isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, @@ -936,6 +937,11 @@ export class StreamableHTTPClientTransport implements Transport { const headers = await this._commonHeaders(); this._applyBodyDerivedHeaders(headers, message); + // A new session starts "without a session ID attached" (2025-11-25 transports §Session Management). + const isHandshake = Array.isArray(message) ? message.some(m => isInitializeRequest(m)) : isInitializeRequest(message); + if (isHandshake) { + headers.delete('mcp-session-id'); + } // Per-request additional headers (the Client passes SEP-2243 // `Mcp-Param-*` here on a 2026-07-28 connection). Reserved // standard/auth header names are skipped so a caller cannot @@ -973,10 +979,10 @@ export class StreamableHTTPClientTransport implements Transport { const response = await (this._fetch ?? fetch)(this._url, init); - // Handle session ID received during initialization - const sessionId = response.headers.get('mcp-session-id'); - if (sessionId) { - this._sessionId = sessionId; + // The spec assigns the session id "at initialization time … on the HTTP response containing the InitializeResult"; it is ignored everywhere else. + // Clients include only an id "returned by the server during initialization", so a sessionless handshake clears any stale id. + if (isHandshake && response.ok) { + this._sessionId = response.headers.get('mcp-session-id') || undefined; } if (!response.ok) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..a36bbc0ad3 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -93,6 +93,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id' @@ -122,6 +123,123 @@ describe('StreamableHTTPClientTransport', () => { expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); }); + it('should not store session ID from an error response, then store it from a later successful initialize', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + // A failed initialize (e.g. a legacy server rejecting a version probe) that carries a session ID + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: () => Promise.resolve('Bad Request'), + headers: new Headers({ 'mcp-session-id': 'poisoned-session-id' }) + }); + + await expect(transport.send(message)).rejects.toThrow(); + expect(transport.sessionId).toBeUndefined(); + + // The fallback initialize succeeds and its session ID is captured + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'real-session-id' }) + }); + + await transport.send(message); + expect(transport.sessionId).toBe('real-session-id'); + }); + + it('should not attach a session ID to an initialize POST, clear a stale ID on a sessionless handshake, and adopt a newly returned one', async () => { + const initMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + const staleTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'stale-session-id' + }); + + // Sessionless handshake: the response carries no session ID + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }) + }); + + await staleTransport.send(initMessage); + + const initCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect(initCall[1].headers.get('mcp-session-id')).toBeNull(); + + // The sessionless handshake cleared the stale ID, so an ordinary request carries none + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await staleTransport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' } as JSONRPCMessage); + expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBeNull(); + + await staleTransport.close().catch(() => {}); + + // When the handshake DOES return a new ID, subsequent requests carry it instead of the preset + const replacedTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'preset-session-id' + }); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'new-session-id' }) + }); + + await replacedTransport.send(initMessage); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await replacedTransport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' } as JSONRPCMessage); + expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBe('new-session-id'); + + await replacedTransport.close().catch(() => {}); + }); + + it('should ignore a session ID on a successful non-initialize response', async () => { + const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'session-a' + }); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers({ 'mcp-session-id': 'session-b' }) + }); + + await sessionTransport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); + expect(sessionTransport.sessionId).toBe('session-a'); + + await sessionTransport.close().catch(() => {}); + }); + it('should accept protocolVersion constructor option and include it in request headers', async () => { // When reconnecting with a preserved sessionId, users need to also preserve the // negotiated protocol version so the required mcp-protocol-version header is sent. @@ -156,6 +274,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id' @@ -196,6 +315,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id'