From 1fb619c753a14abcd476415534446eb88f17fb91 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 25 Feb 2026 22:38:43 -0500 Subject: [PATCH] Restore negotiated protocol version on reconnection transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Client.connect() is called with a transport that has a pre-set sessionId (reconnection), it skips the initialize handshake. Previously this meant transport.setProtocolVersion() was never called, so all subsequent HTTP requests omitted the mcp-protocol-version header — violating the MCP spec's MUST requirement. This change: - Stores the negotiated protocol version in Client during the initial handshake - Restores it onto the new transport in the reconnection early-return path - Adds a getNegotiatedProtocolVersion() getter for manual transport construction - Adds a protocolVersion option to StreamableHTTPClientTransportOptions for users who create a fresh Client on reconnect Addresses #812, #731 --- packages/client/src/client/client.ts | 17 +++++- packages/client/src/client/streamableHttp.ts | 8 +++ .../client/test/client/streamableHttp.test.ts | 27 ++++++++++ test/integration/test/client/client.test.ts | 52 +++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index c2d9996248..95d053d1b1 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -195,6 +195,7 @@ export type ClientOptions = ProtocolOptions & { export class Client extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; + private _negotiatedProtocolVersion?: string; private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -471,8 +472,12 @@ export class Client extends Protocol { override async connect(transport: Transport, options?: RequestOptions): Promise { await super.connect(transport); // When transport sessionId is already set this means we are trying to reconnect. - // In this case we don't need to initialize again. + // Restore the protocol version negotiated during the original initialize handshake + // so HTTP transports include the required mcp-protocol-version header, but skip re-init. if (transport.sessionId !== undefined) { + if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { + transport.setProtocolVersion(this._negotiatedProtocolVersion); + } return; } try { @@ -499,6 +504,7 @@ export class Client extends Protocol { this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; + this._negotiatedProtocolVersion = result.protocolVersion; // HTTP transports must set the protocol version in each header after initialization. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.protocolVersion); @@ -536,6 +542,15 @@ export class Client extends Protocol { return this._serverVersion; } + /** + * After initialization has completed, this will be populated with the protocol version negotiated + * during the initialize handshake. When manually reconstructing a transport for reconnection, pass this + * value to the new transport so it continues sending the required `mcp-protocol-version` header. + */ + getNegotiatedProtocolVersion(): string | undefined { + return this._negotiatedProtocolVersion; + } + /** * After initialization has completed, this may be populated with information about the server's instructions. */ diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 79a20adfc7..6b3dd755fb 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -118,6 +118,13 @@ export type StreamableHTTPClientTransportOptions = { * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. */ sessionId?: string; + + /** + * The MCP protocol version to include in the `mcp-protocol-version` header on all requests. + * When reconnecting with a preserved `sessionId`, set this to the version negotiated during the original + * handshake so the reconnected transport continues sending the required header. + */ + protocolVersion?: string; }; /** @@ -155,6 +162,7 @@ export class StreamableHTTPClientTransport implements Transport { this._fetch = opts?.fetch; this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); this._sessionId = opts?.sessionId; + this._protocolVersion = opts?.protocolVersion; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 8a550feaea..873196a3a7 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -122,6 +122,33 @@ describe('StreamableHTTPClientTransport', () => { expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); }); + 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. + const reconnectTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'preserved-session-id', + protocolVersion: '2025-11-25' + }); + + expect(reconnectTransport.sessionId).toBe('preserved-session-id'); + expect(reconnectTransport.protocolVersion).toBe('2025-11-25'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await reconnectTransport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + + const calls = (globalThis.fetch as Mock).mock.calls; + const lastCall = calls.at(-1)!; + expect(lastCall[1].headers.get('mcp-session-id')).toBe('preserved-session-id'); + expect(lastCall[1].headers.get('mcp-protocol-version')).toBe('2025-11-25'); + + await reconnectTransport.close().catch(() => {}); + }); + it('should terminate session with DELETE request', async () => { // First, simulate getting a session ID const message: JSONRPCMessage = { diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 3c57fc5424..562baace77 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -124,6 +124,58 @@ test('should initialize with supported older protocol version', async () => { expect(client.getInstructions()).toBeUndefined(); }); +/*** + * Test: Reconnecting with the same Client restores protocol version on new transport + */ +test('should restore negotiated protocol version on transport when reconnecting with same client', async () => { + const setProtocolVersion = vi.fn(); + const initialTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + setProtocolVersion, + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + initialTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { name: 'test', version: '1.0' } + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client({ name: 'test client', version: '1.0' }); + await client.connect(initialTransport); + + // Initial handshake should have set the protocol version on the transport + expect(setProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + // Now simulate reconnection: new transport with a pre-existing sessionId. + // connect() will early-return without re-initializing, but MUST restore the protocol version + // so HTTP transports can keep sending the required mcp-protocol-version header. + const reconnectSetProtocolVersion = vi.fn(); + const reconnectTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + setProtocolVersion: reconnectSetProtocolVersion, + send: vi.fn().mockResolvedValue(undefined), + sessionId: 'existing-session-id' + }; + + await client.connect(reconnectTransport); + + // No initialize request should have been sent (sessionId was set) + expect(reconnectTransport.send).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'initialize' }), expect.anything()); + // But the protocol version MUST have been restored onto the new transport + expect(reconnectSetProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); +}); + /*** * Test: Reject Unsupported Protocol Version */