Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/feat-stdio-protocol-version.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/client/src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*/
Expand Down
9 changes: 9 additions & 0 deletions packages/client/test/client/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ export class Server extends Protocol<ServerContext> {
? requestedVersion
: (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION);

this.transport?.setProtocolVersion?.(protocolVersion);

return {
protocolVersion,
capabilities: this.getCapabilities(),
Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/server/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions packages/server/test/server/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading