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); + }); +});