From c769d1978aabbc3e76062e6f9cb3f9b573b8c3e0 Mon Sep 17 00:00:00 2001 From: rechedev9 Date: Wed, 11 Mar 2026 19:07:46 +0100 Subject: [PATCH] feat: expose negotiated protocol version on stdio transports Add setProtocolVersion() method and protocolVersion getter to both StdioServerTransport and StdioClientTransport, so callers can inspect the MCP protocol version negotiated during the initialize handshake. - Client already calls transport.setProtocolVersion() after handshake, so StdioClientTransport now surfaces that value. - Server._oninitialize() now calls transport.setProtocolVersion?() so StdioServerTransport is populated on the server side as well. Closes #1468 --- .changeset/feat-stdio-protocol-version.md | 15 ++++ packages/client/src/client/stdio.ts | 15 ++++ packages/client/test/client/stdio.test.ts | 9 +++ packages/server/src/server/server.ts | 2 + packages/server/src/server/stdio.ts | 15 ++++ packages/server/test/server/stdio.test.ts | 7 ++ .../test1468.stdio-protocol-version.test.ts | 80 +++++++++++++++++++ 7 files changed, 143 insertions(+) create mode 100644 .changeset/feat-stdio-protocol-version.md create mode 100644 test/integration/test/issues/test1468.stdio-protocol-version.test.ts diff --git a/.changeset/feat-stdio-protocol-version.md b/.changeset/feat-stdio-protocol-version.md new file mode 100644 index 000000000..4314e806a --- /dev/null +++ b/.changeset/feat-stdio-protocol-version.md @@ -0,0 +1,15 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +feat: expose negotiated protocol version on stdio transports + +`StdioServerTransport` and `StdioClientTransport` now implement `setProtocolVersion()` +and expose a `protocolVersion` getter, making it possible to inspect the negotiated MCP +protocol version after initialization completes over stdio connections. + +Previously this was only available on HTTP-based transports (where it is required for +header injection). After this change, `Client` automatically populates the version on +`StdioClientTransport`, and `Server` populates it on `StdioServerTransport` during the +`initialize` handshake — matching the existing behaviour for HTTP transports. diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index 6c1571f11..9e0a1c8fa 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -95,6 +95,7 @@ export class StdioClientTransport implements Transport { private _readBuffer: ReadBuffer = new ReadBuffer(); private _serverParams: StdioServerParameters; private _stderrStream: PassThrough | null = null; + private _protocolVersion: string | undefined; onclose?: () => void; onerror?: (error: Error) => void; @@ -107,6 +108,20 @@ export class StdioClientTransport implements Transport { } } + /** + * Sets the negotiated protocol version (called by the client after initialization). + */ + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + + /** + * The negotiated MCP protocol version, available after initialization completes. + */ + get protocolVersion(): string | undefined { + return this._protocolVersion; + } + /** * Starts the server process and prepares to communicate with it. */ diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts index 28a7834bc..78eb0e013 100644 --- a/packages/client/test/client/stdio.test.ts +++ b/packages/client/test/client/stdio.test.ts @@ -69,6 +69,15 @@ test('should read messages', async () => { await client.close(); }); +test('should expose protocol version after setProtocolVersion', async () => { + const client = new StdioClientTransport(serverParameters); + expect(client.protocolVersion).toBeUndefined(); + await client.start(); + client.setProtocolVersion('2025-11-25'); + expect(client.protocolVersion).toBe('2025-11-25'); + await client.close(); +}); + test('should return child process pid', async () => { const client = new StdioClientTransport(serverParameters); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 00d3e6f52..7ffe15174 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -438,6 +438,8 @@ export class Server extends Protocol { ? requestedVersion : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); + this.transport?.setProtocolVersion?.(protocolVersion); + return { protocolVersion, capabilities: this.getCapabilities(), diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 562c6861c..4a61931df 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -19,12 +19,27 @@ import { process } from '@modelcontextprotocol/server/_shims'; export class StdioServerTransport implements Transport { private _readBuffer: ReadBuffer = new ReadBuffer(); private _started = false; + private _protocolVersion: string | undefined; constructor( private _stdin: Readable = process.stdin, private _stdout: Writable = process.stdout ) {} + /** + * Sets the negotiated protocol version (called by the server after initialization). + */ + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + + /** + * The negotiated MCP protocol version, available after initialization completes. + */ + get protocolVersion(): string | undefined { + return this._protocolVersion; + } + onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 8b1f234b9..36e52517a 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -67,6 +67,13 @@ test('should not read until started', async () => { expect(await readMessage).toEqual(message); }); +test('should expose protocol version after setProtocolVersion', async () => { + const server = new StdioServerTransport(input, output); + expect(server.protocolVersion).toBeUndefined(); + server.setProtocolVersion('2025-11-25'); + expect(server.protocolVersion).toBe('2025-11-25'); +}); + test('should read multiple messages', async () => { const server = new StdioServerTransport(input, output); server.onerror = error => { diff --git a/test/integration/test/issues/test1468.stdio-protocol-version.test.ts b/test/integration/test/issues/test1468.stdio-protocol-version.test.ts new file mode 100644 index 000000000..e07e1e16e --- /dev/null +++ b/test/integration/test/issues/test1468.stdio-protocol-version.test.ts @@ -0,0 +1,80 @@ +/** + * Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1468 + * + * After MCP initialization completes over stdio, both the client transport and the server + * transport should expose the negotiated protocol version via a `protocolVersion` getter. + * + * - `Client` already calls `transport.setProtocolVersion()` after the handshake, so + * `StdioClientTransport` merely needs to store and surface that value. + * - `Server._oninitialize()` now calls `transport.setProtocolVersion?.()`, so + * `StdioServerTransport` is populated on the server side as well. + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; + +/** A thin wrapper around InMemoryTransport that records setProtocolVersion calls. */ +function makeVersionRecordingTransport(inner: Transport): Transport & { recordedVersion: string | undefined } { + let recordedVersion: string | undefined; + return { + get recordedVersion() { + return recordedVersion; + }, + get onclose() { + return inner.onclose; + }, + set onclose(v) { + inner.onclose = v; + }, + get onerror() { + return inner.onerror; + }, + set onerror(v) { + inner.onerror = v; + }, + get onmessage() { + return inner.onmessage; + }, + set onmessage(v) { + inner.onmessage = v; + }, + start: () => inner.start(), + close: () => inner.close(), + send: (msg: JSONRPCMessage) => inner.send(msg), + setProtocolVersion(version: string) { + recordedVersion = version; + } + }; +} + +describe('Issue #1468: stdio transports expose negotiated protocol version', () => { + test('Server calls transport.setProtocolVersion() after initialization', async () => { + const [rawClient, rawServer] = InMemoryTransport.createLinkedPair(); + + const serverTransport = makeVersionRecordingTransport(rawServer); + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + expect(serverTransport.recordedVersion).toBeUndefined(); + + await Promise.all([client.connect(rawClient), mcpServer.server.connect(serverTransport)]); + + expect(serverTransport.recordedVersion).toBe(LATEST_PROTOCOL_VERSION); + }); + + test('Client calls transport.setProtocolVersion() after initialization', async () => { + const [rawClient, rawServer] = InMemoryTransport.createLinkedPair(); + + const clientTransport = makeVersionRecordingTransport(rawClient); + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + expect(clientTransport.recordedVersion).toBeUndefined(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(rawServer)]); + + expect(clientTransport.recordedVersion).toBe(LATEST_PROTOCOL_VERSION); + }); +});