Skip to content

Commit 9b41b56

Browse files
fix(client): initialize requests never carry a session id; capture only from the initialize response (#2469)
1 parent 44797d7 commit 9b41b56

3 files changed

Lines changed: 135 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
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.

packages/client/src/client/streamableHttp.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createFetchWithInit,
66
encodeMcpParamValue,
77
isInitializedNotification,
8+
isInitializeRequest,
89
isJSONRPCErrorResponse,
910
isJSONRPCRequest,
1011
isJSONRPCResultResponse,
@@ -936,6 +937,11 @@ export class StreamableHTTPClientTransport implements Transport {
936937

937938
const headers = await this._commonHeaders();
938939
this._applyBodyDerivedHeaders(headers, message);
940+
// A new session starts "without a session ID attached" (2025-11-25 transports §Session Management).
941+
const isHandshake = Array.isArray(message) ? message.some(m => isInitializeRequest(m)) : isInitializeRequest(message);
942+
if (isHandshake) {
943+
headers.delete('mcp-session-id');
944+
}
939945
// Per-request additional headers (the Client passes SEP-2243
940946
// `Mcp-Param-*` here on a 2026-07-28 connection). Reserved
941947
// standard/auth header names are skipped so a caller cannot
@@ -973,10 +979,10 @@ export class StreamableHTTPClientTransport implements Transport {
973979

974980
const response = await (this._fetch ?? fetch)(this._url, init);
975981

976-
// Handle session ID received during initialization
977-
const sessionId = response.headers.get('mcp-session-id');
978-
if (sessionId) {
979-
this._sessionId = sessionId;
982+
// The spec assigns the session id "at initialization time … on the HTTP response containing the InitializeResult"; it is ignored everywhere else.
983+
// Clients include only an id "returned by the server during initialization", so a sessionless handshake clears any stale id.
984+
if (isHandshake && response.ok) {
985+
this._sessionId = response.headers.get('mcp-session-id') || undefined;
980986
}
981987

982988
if (!response.ok) {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe('StreamableHTTPClientTransport', () => {
9393
method: 'initialize',
9494
params: {
9595
clientInfo: { name: 'test-client', version: '1.0' },
96+
capabilities: {},
9697
protocolVersion: '2025-03-26'
9798
},
9899
id: 'init-id'
@@ -122,6 +123,123 @@ describe('StreamableHTTPClientTransport', () => {
122123
expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id');
123124
});
124125

126+
it('should not store session ID from an error response, then store it from a later successful initialize', async () => {
127+
const message: JSONRPCMessage = {
128+
jsonrpc: '2.0',
129+
method: 'initialize',
130+
params: {
131+
clientInfo: { name: 'test-client', version: '1.0' },
132+
capabilities: {},
133+
protocolVersion: '2025-03-26'
134+
},
135+
id: 'init-id'
136+
};
137+
138+
// A failed initialize (e.g. a legacy server rejecting a version probe) that carries a session ID
139+
(globalThis.fetch as Mock).mockResolvedValueOnce({
140+
ok: false,
141+
status: 400,
142+
statusText: 'Bad Request',
143+
text: () => Promise.resolve('Bad Request'),
144+
headers: new Headers({ 'mcp-session-id': 'poisoned-session-id' })
145+
});
146+
147+
await expect(transport.send(message)).rejects.toThrow();
148+
expect(transport.sessionId).toBeUndefined();
149+
150+
// The fallback initialize succeeds and its session ID is captured
151+
(globalThis.fetch as Mock).mockResolvedValueOnce({
152+
ok: true,
153+
status: 200,
154+
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'real-session-id' })
155+
});
156+
157+
await transport.send(message);
158+
expect(transport.sessionId).toBe('real-session-id');
159+
});
160+
161+
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 () => {
162+
const initMessage: JSONRPCMessage = {
163+
jsonrpc: '2.0',
164+
method: 'initialize',
165+
params: {
166+
clientInfo: { name: 'test-client', version: '1.0' },
167+
capabilities: {},
168+
protocolVersion: '2025-03-26'
169+
},
170+
id: 'init-id'
171+
};
172+
173+
const staleTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
174+
sessionId: 'stale-session-id'
175+
});
176+
177+
// Sessionless handshake: the response carries no session ID
178+
(globalThis.fetch as Mock).mockResolvedValueOnce({
179+
ok: true,
180+
status: 200,
181+
headers: new Headers({ 'content-type': 'text/event-stream' })
182+
});
183+
184+
await staleTransport.send(initMessage);
185+
186+
const initCall = (globalThis.fetch as Mock).mock.calls.at(-1)!;
187+
expect(initCall[1].headers.get('mcp-session-id')).toBeNull();
188+
189+
// The sessionless handshake cleared the stale ID, so an ordinary request carries none
190+
(globalThis.fetch as Mock).mockResolvedValueOnce({
191+
ok: true,
192+
status: 202,
193+
headers: new Headers()
194+
});
195+
196+
await staleTransport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' } as JSONRPCMessage);
197+
expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBeNull();
198+
199+
await staleTransport.close().catch(() => {});
200+
201+
// When the handshake DOES return a new ID, subsequent requests carry it instead of the preset
202+
const replacedTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
203+
sessionId: 'preset-session-id'
204+
});
205+
206+
(globalThis.fetch as Mock).mockResolvedValueOnce({
207+
ok: true,
208+
status: 200,
209+
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'new-session-id' })
210+
});
211+
212+
await replacedTransport.send(initMessage);
213+
214+
(globalThis.fetch as Mock).mockResolvedValueOnce({
215+
ok: true,
216+
status: 202,
217+
headers: new Headers()
218+
});
219+
220+
await replacedTransport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' } as JSONRPCMessage);
221+
expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBe('new-session-id');
222+
223+
await replacedTransport.close().catch(() => {});
224+
});
225+
226+
it('should ignore a session ID on a successful non-initialize response', async () => {
227+
const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
228+
sessionId: 'session-a'
229+
});
230+
231+
(globalThis.fetch as Mock).mockResolvedValueOnce({
232+
ok: true,
233+
status: 202,
234+
headers: new Headers({ 'mcp-session-id': 'session-b' })
235+
});
236+
237+
await sessionTransport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage);
238+
expect(sessionTransport.sessionId).toBe('session-a');
239+
240+
await sessionTransport.close().catch(() => {});
241+
});
242+
125243
it('should accept protocolVersion constructor option and include it in request headers', async () => {
126244
// When reconnecting with a preserved sessionId, users need to also preserve the
127245
// negotiated protocol version so the required mcp-protocol-version header is sent.
@@ -156,6 +274,7 @@ describe('StreamableHTTPClientTransport', () => {
156274
method: 'initialize',
157275
params: {
158276
clientInfo: { name: 'test-client', version: '1.0' },
277+
capabilities: {},
159278
protocolVersion: '2025-03-26'
160279
},
161280
id: 'init-id'
@@ -196,6 +315,7 @@ describe('StreamableHTTPClientTransport', () => {
196315
method: 'initialize',
197316
params: {
198317
clientInfo: { name: 'test-client', version: '1.0' },
318+
capabilities: {},
199319
protocolVersion: '2025-03-26'
200320
},
201321
id: 'init-id'

0 commit comments

Comments
 (0)