From e743f409affe39d672c87e596f918d31fcb151bd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 12:31:50 +0000 Subject: [PATCH 1/2] fix(client): initialize requests never carry a session id; capture only from the initialize response Per the Streamable HTTP transport spec (2025-11-25, Session Management), a new session starts with an initialize request sent without a session ID attached, and the server assigns the session ID on the HTTP response containing the InitializeResult. The client transport previously attached any stored session ID to initialize POSTs and stored the mcp-session-id header from every response, including error responses. A server answering a version-negotiation probe with an error that carried a session ID would poison the subsequent initialize, which then went out with a session ID it should never have had. Now the POST path strips mcp-session-id from any request containing an initialize, and captures the response header only when that same request contained an initialize and the response was successful. --- .changeset/initialize-session-hygiene.md | 5 + packages/client/src/client/streamableHttp.ts | 16 +++- .../client/test/client/streamableHttp.test.ts | 95 +++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 .changeset/initialize-session-hygiene.md diff --git a/.changeset/initialize-session-hygiene.md b/.changeset/initialize-session-hygiene.md new file mode 100644 index 0000000000..4546f8140c --- /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. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0d8eff917b..2cba3da598 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,12 @@ 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. + if (isHandshake && response.ok) { + const sessionId = response.headers.get('mcp-session-id'); + if (sessionId) { + this._sessionId = sessionId; + } } if (!response.ok) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..4563e35e04 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,98 @@ 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 the session ID header to a POST containing an initialize request', async () => { + const staleTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'stale-session-id' + }); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await staleTransport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + } as JSONRPCMessage); + + const initCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect(initCall[1].headers.get('mcp-session-id')).toBeNull(); + + // An ordinary request still carries the session ID + (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); + + const ordinaryCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect(ordinaryCall[1].headers.get('mcp-session-id')).toBe('stale-session-id'); + + await staleTransport.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 +249,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id' @@ -196,6 +290,7 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, protocolVersion: '2025-03-26' }, id: 'init-id' From 3b3dc831c5f77f90649c63c4401e0eee58b1db5c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 13:10:46 +0000 Subject: [PATCH 2/2] fix(client): sessionless initialize response clears any stale session id Mirror the handshake response exactly: on a successful POST containing an initialize request, assign the session id from the mcp-session-id response header, treating an absent or empty header as no session. Previously a session id preset via the constructor survived a handshake whose response carried no session id, so the stale id rode every subsequent request even though the server never issued it this session. Clients include only an id returned by the server during initialization; the spec's only rotation mechanism is session replacement (404 + re-initialize), never a header swap on a live session. Also align test fixtures with the spec: initialize is a request, so its response mock is 200, not 202 (202 is for notification/response-only POSTs). --- .changeset/initialize-session-hygiene.md | 2 +- packages/client/src/client/streamableHttp.ts | 6 +- .../client/test/client/streamableHttp.test.ts | 59 +++++++++++++------ 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.changeset/initialize-session-hygiene.md b/.changeset/initialize-session-hygiene.md index 4546f8140c..b12e3c514f 100644 --- a/.changeset/initialize-session-hygiene.md +++ b/.changeset/initialize-session-hygiene.md @@ -2,4 +2,4 @@ '@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. +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 2cba3da598..9067fd1ec4 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -980,11 +980,9 @@ export class StreamableHTTPClientTransport implements Transport { const response = await (this._fetch ?? fetch)(this._url, init); // 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) { - const sessionId = response.headers.get('mcp-session-id'); - if (sessionId) { - this._sessionId = sessionId; - } + 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 4563e35e04..a36bbc0ad3 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -158,18 +158,8 @@ describe('StreamableHTTPClientTransport', () => { expect(transport.sessionId).toBe('real-session-id'); }); - it('should not attach the session ID header to a POST containing an initialize request', async () => { - const staleTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { - sessionId: 'stale-session-id' - }); - - (globalThis.fetch as Mock).mockResolvedValueOnce({ - ok: true, - status: 202, - headers: new Headers() - }); - - await staleTransport.send({ + 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: { @@ -178,12 +168,25 @@ describe('StreamableHTTPClientTransport', () => { protocolVersion: '2025-03-26' }, id: 'init-id' - } as JSONRPCMessage); + }; + + 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(); - // An ordinary request still carries the session ID + // The sessionless handshake cleared the stale ID, so an ordinary request carries none (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, @@ -191,11 +194,33 @@ describe('StreamableHTTPClientTransport', () => { }); await staleTransport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' } as JSONRPCMessage); - - const ordinaryCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; - expect(ordinaryCall[1].headers.get('mcp-session-id')).toBe('stale-session-id'); + 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 () => {