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
2 changed files with 135 additions and 4 deletions
|
|
@ -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