Skip to content

Commit 72fe68b

Browse files
bhosmer-antmattzcareyfelixweinberger
authored
Restore negotiated protocol version on reconnection transport (#1591)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent 0784be1 commit 72fe68b

File tree

4 files changed

+103
-1
lines changed

4 files changed

+103
-1
lines changed

packages/client/src/client/client.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export type ClientOptions = ProtocolOptions & {
194194
export class Client extends Protocol<ClientContext> {
195195
private _serverCapabilities?: ServerCapabilities;
196196
private _serverVersion?: Implementation;
197+
private _negotiatedProtocolVersion?: string;
197198
private _capabilities: ClientCapabilities;
198199
private _instructions?: string;
199200
private _jsonSchemaValidator: jsonSchemaValidator;
@@ -470,8 +471,12 @@ export class Client extends Protocol<ClientContext> {
470471
override async connect(transport: Transport, options?: RequestOptions): Promise<void> {
471472
await super.connect(transport);
472473
// When transport sessionId is already set this means we are trying to reconnect.
473-
// In this case we don't need to initialize again.
474+
// Restore the protocol version negotiated during the original initialize handshake
475+
// so HTTP transports include the required mcp-protocol-version header, but skip re-init.
474476
if (transport.sessionId !== undefined) {
477+
if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) {
478+
transport.setProtocolVersion(this._negotiatedProtocolVersion);
479+
}
475480
return;
476481
}
477482
try {
@@ -498,6 +503,7 @@ export class Client extends Protocol<ClientContext> {
498503

499504
this._serverCapabilities = result.capabilities;
500505
this._serverVersion = result.serverInfo;
506+
this._negotiatedProtocolVersion = result.protocolVersion;
501507
// HTTP transports must set the protocol version in each header after initialization.
502508
if (transport.setProtocolVersion) {
503509
transport.setProtocolVersion(result.protocolVersion);
@@ -535,6 +541,15 @@ export class Client extends Protocol<ClientContext> {
535541
return this._serverVersion;
536542
}
537543

544+
/**
545+
* After initialization has completed, this will be populated with the protocol version negotiated
546+
* during the initialize handshake. When manually reconstructing a transport for reconnection, pass this
547+
* value to the new transport so it continues sending the required `mcp-protocol-version` header.
548+
*/
549+
getNegotiatedProtocolVersion(): string | undefined {
550+
return this._negotiatedProtocolVersion;
551+
}
552+
538553
/**
539554
* After initialization has completed, this may be populated with information about the server's instructions.
540555
*/

packages/client/src/client/streamableHttp.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ export type StreamableHTTPClientTransportOptions = {
118118
* When not provided and connecting to a server that supports session IDs, the server will generate a new session ID.
119119
*/
120120
sessionId?: string;
121+
122+
/**
123+
* The MCP protocol version to include in the `mcp-protocol-version` header on all requests.
124+
* When reconnecting with a preserved `sessionId`, set this to the version negotiated during the original
125+
* handshake so the reconnected transport continues sending the required header.
126+
*/
127+
protocolVersion?: string;
121128
};
122129

123130
/**
@@ -155,6 +162,7 @@ export class StreamableHTTPClientTransport implements Transport {
155162
this._fetch = opts?.fetch;
156163
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
157164
this._sessionId = opts?.sessionId;
165+
this._protocolVersion = opts?.protocolVersion;
158166
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
159167
}
160168

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@ describe('StreamableHTTPClientTransport', () => {
122122
expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id');
123123
});
124124

125+
it('should accept protocolVersion constructor option and include it in request headers', async () => {
126+
// When reconnecting with a preserved sessionId, users need to also preserve the
127+
// negotiated protocol version so the required mcp-protocol-version header is sent.
128+
const reconnectTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
129+
sessionId: 'preserved-session-id',
130+
protocolVersion: '2025-11-25'
131+
});
132+
133+
expect(reconnectTransport.sessionId).toBe('preserved-session-id');
134+
expect(reconnectTransport.protocolVersion).toBe('2025-11-25');
135+
136+
(globalThis.fetch as Mock).mockResolvedValueOnce({
137+
ok: true,
138+
status: 202,
139+
headers: new Headers()
140+
});
141+
142+
await reconnectTransport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
143+
144+
const calls = (globalThis.fetch as Mock).mock.calls;
145+
const lastCall = calls.at(-1)!;
146+
expect(lastCall[1].headers.get('mcp-session-id')).toBe('preserved-session-id');
147+
expect(lastCall[1].headers.get('mcp-protocol-version')).toBe('2025-11-25');
148+
149+
await reconnectTransport.close().catch(() => {});
150+
});
151+
125152
it('should terminate session with DELETE request', async () => {
126153
// First, simulate getting a session ID
127154
const message: JSONRPCMessage = {

test/integration/test/client/client.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,58 @@ test('should initialize with supported older protocol version', async () => {
122122
expect(client.getInstructions()).toBeUndefined();
123123
});
124124

125+
/***
126+
* Test: Reconnecting with the same Client restores protocol version on new transport
127+
*/
128+
test('should restore negotiated protocol version on transport when reconnecting with same client', async () => {
129+
const setProtocolVersion = vi.fn();
130+
const initialTransport: Transport = {
131+
start: vi.fn().mockResolvedValue(undefined),
132+
close: vi.fn().mockResolvedValue(undefined),
133+
setProtocolVersion,
134+
send: vi.fn().mockImplementation(message => {
135+
if (message.method === 'initialize') {
136+
initialTransport.onmessage?.({
137+
jsonrpc: '2.0',
138+
id: message.id,
139+
result: {
140+
protocolVersion: LATEST_PROTOCOL_VERSION,
141+
capabilities: {},
142+
serverInfo: { name: 'test', version: '1.0' }
143+
}
144+
});
145+
}
146+
return Promise.resolve();
147+
})
148+
};
149+
150+
const client = new Client({ name: 'test client', version: '1.0' });
151+
await client.connect(initialTransport);
152+
153+
// Initial handshake should have set the protocol version on the transport
154+
expect(setProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION);
155+
expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION);
156+
157+
// Now simulate reconnection: new transport with a pre-existing sessionId.
158+
// connect() will early-return without re-initializing, but MUST restore the protocol version
159+
// so HTTP transports can keep sending the required mcp-protocol-version header.
160+
const reconnectSetProtocolVersion = vi.fn();
161+
const reconnectTransport: Transport = {
162+
start: vi.fn().mockResolvedValue(undefined),
163+
close: vi.fn().mockResolvedValue(undefined),
164+
setProtocolVersion: reconnectSetProtocolVersion,
165+
send: vi.fn().mockResolvedValue(undefined),
166+
sessionId: 'existing-session-id'
167+
};
168+
169+
await client.connect(reconnectTransport);
170+
171+
// No initialize request should have been sent (sessionId was set)
172+
expect(reconnectTransport.send).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'initialize' }), expect.anything());
173+
// But the protocol version MUST have been restored onto the new transport
174+
expect(reconnectSetProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION);
175+
});
176+
125177
/***
126178
* Test: Reject Unsupported Protocol Version
127179
*/

0 commit comments

Comments
 (0)