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>
This commit is contained in:
bhosmer-ant 2026-03-23 10:58:13 -04:00 • committed by GitHub
commit 72fe68b266
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 103 additions and 1 deletions

View file

@ -194,6 +194,7 @@ export type ClientOptions = ProtocolOptions & {
export class Client extends Protocol<ClientContext> {
private _serverCapabilities?: ServerCapabilities;
private _serverVersion?: Implementation;
private _negotiatedProtocolVersion?: string;
private _capabilities: ClientCapabilities;
private _instructions?: string;
private _jsonSchemaValidator: jsonSchemaValidator;
@ -470,8 +471,12 @@ export class Client extends Protocol<ClientContext> {
override async connect(transport: Transport, options?: RequestOptions): Promise<void> {
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 {
@ -498,6 +503,7 @@ export class Client extends Protocol<ClientContext> {
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);
@ -535,6 +541,15 @@ export class Client extends Protocol<ClientContext> {
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.
*/

View file

@ -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;
}

View file

@ -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 = {

View file

@ -122,6 +122,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
*/