fix(client): initialize requests never carry a session id; capture only from the initialize response (#2469)
This commit is contained in:
parent
44797d7779
commit
9b41b5685d
3 changed files with 135 additions and 4 deletions
5
.changeset/initialize-session-hygiene.md
Normal file
5
.changeset/initialize-session-hygiene.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue