Skip to content

Commit eacf2e5

Browse files
Add --protocol-version flag to cli-client example (#2406)
1 parent 448ba0f commit eacf2e5

4 files changed

Lines changed: 92 additions & 29 deletions

File tree

examples/cli-client/README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pnpm --filter @mcp-examples/todos-server start:http
4848
ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --provider anthropic
4949
```
5050

51-
The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`).
51+
The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`). To hold the connection to one exact revision, use `--protocol-version 2025-06-18` (or any supported revision) — the connection fails rather than settle on anything else.
5252

5353
A tour that touches everything, in one sitting:
5454

@@ -111,14 +111,15 @@ For a persistent setup, copy `config.example.json` to `config.json` (or pass `--
111111
## All flags
112112

113113
```text
114-
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
115-
--config <path> mcpServers config file (default: ./config.json, falling back to spawning todos-server)
116-
--provider <name> scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted)
117-
--model <id> pin a model id (default: the provider's latest mid-tier model)
118-
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
119-
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port)
120-
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
121-
-h, --help show usage
114+
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
115+
--config <path> mcpServers config file (default: ./config.json, falling back to spawning todos-server)
116+
--provider <name> scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted)
117+
--model <id> pin a model id (default: the provider's latest mid-tier model)
118+
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
119+
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port)
120+
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
121+
--protocol-version <v> negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin
122+
-h, --help show usage
122123
```
123124

124125
## How this example is tested

examples/cli-client/cli.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ import type { LLMProvider } from './providers/provider';
2424
import { ScriptedProvider } from './providers/scripted';
2525

2626
const USAGE = `usage: tsx cli.ts [options]
27-
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
28-
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
29-
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
30-
--model <id> pin a model id (default: the provider's latest mid-tier model)
31-
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
32-
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH)
33-
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
34-
--help this help`;
27+
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
28+
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
29+
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
30+
--model <id> pin a model id (default: the provider's latest mid-tier model)
31+
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
32+
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH)
33+
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
34+
--protocol-version <v> negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin
35+
--help this help`;
3536

3637
function pickProvider(name: string | undefined, model: string | undefined): LLMProvider {
3738
const chosen =
@@ -73,6 +74,7 @@ const { values } = parseArgs({
7374
root: { type: 'string', multiple: true },
7475
'callback-port': { type: 'string' },
7576
legacy: { type: 'boolean' },
77+
'protocol-version': { type: 'string' },
7678
help: { type: 'boolean', short: 'h' }
7779
}
7880
});
@@ -111,15 +113,17 @@ for (const [serverName, entry] of Object.entries(config.mcpServers)) {
111113
ui.status(` ${serverName}${'url' in entry ? entry.url : [entry.command, ...(entry.args ?? [])].join(' ')}`);
112114
}
113115

114-
const host = new McpHost({
115-
ui,
116-
provider,
117-
roots: values.root ?? [process.cwd()],
118-
legacy: values.legacy ?? false,
119-
oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined
120-
});
121-
hostRef.current = host;
116+
let host: McpHost;
122117
try {
118+
host = new McpHost({
119+
ui,
120+
provider,
121+
roots: values.root ?? [process.cwd()],
122+
legacy: values.legacy ?? false,
123+
protocolVersion: values['protocol-version'],
124+
oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined
125+
});
126+
hostRef.current = host;
123127
await host.connect(config);
124128
} catch (error) {
125129
ui.print(error instanceof Error ? error.message : String(error));

examples/cli-client/host/host.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ import type {
1010
Prompt,
1111
Resource,
1212
ResourceTemplateType,
13-
Tool
13+
Tool,
14+
VersionNegotiationOptions
1415
} from '@modelcontextprotocol/client';
1516
import {
1617
Client,
1718
LOG_LEVEL_META_KEY,
1819
ProtocolError,
1920
SdkError,
2021
StreamableHTTPClientTransport,
22+
SUPPORTED_PROTOCOL_VERSIONS,
2123
UnauthorizedError
2224
} from '@modelcontextprotocol/client';
2325
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
@@ -61,10 +63,43 @@ export interface McpHostOptions {
6163
roots?: string[];
6264
/** Use the 2025 `initialize` handshake instead of probing for 2026-07-28. */
6365
legacy?: boolean;
66+
/**
67+
* Negotiate exactly this protocol revision: a known 2025-era value runs the legacy
68+
* handshake offering only that revision; anything else is pinned via the modern
69+
* handshake, which fails loudly unless the server offers it.
70+
*/
71+
protocolVersion?: string;
6472
/** Fixed loopback port for the OAuth callback (default: an OS-assigned free port). Useful over SSH port-forwarding. */
6573
oauthCallbackPort?: number;
6674
}
6775

76+
/** The version-negotiation slice of the SDK client options every connection this host makes shares. */
77+
export interface VersionOptions {
78+
versionNegotiation: VersionNegotiationOptions;
79+
supportedProtocolVersions?: string[];
80+
}
81+
82+
/**
83+
* Map the era toggle and optional pinned revision onto the SDK's negotiation options.
84+
* A known 2025-era revision runs the legacy handshake offering exactly that revision
85+
* (the client rejects a server that answers with any other version); everything else
86+
* becomes a modern pin, and the SDK's own typed error covers strings that are neither.
87+
*/
88+
export function resolveVersionOptions(legacy: boolean, protocolVersion?: string): VersionOptions {
89+
if (protocolVersion === undefined) {
90+
return { versionNegotiation: { mode: legacy ? 'legacy' : 'auto' } };
91+
}
92+
if (SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
93+
return { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: [protocolVersion] };
94+
}
95+
if (legacy) {
96+
throw new Error(
97+
`--legacy conflicts with --protocol-version ${protocolVersion}: the 2025 handshake can only negotiate ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}`
98+
);
99+
}
100+
return { versionNegotiation: { mode: { pin: protocolVersion } } };
101+
}
102+
68103
function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined {
69104
if (error instanceof UnauthorizedError) return error;
70105
// Under versionNegotiation 'auto', a connect-time 401 surfaces as
@@ -94,7 +129,7 @@ function samplingContentToParts(content: CreateMessageRequest['params']['message
94129
export class McpHost {
95130
private readonly ui: HostUI;
96131
private readonly provider: LLMProvider;
97-
private readonly legacy: boolean;
132+
private readonly versionOptions: VersionOptions;
98133
private roots: string[];
99134
private readonly watches: McpSubscription[] = [];
100135
private readonly oauthCallbackPort?: number;
@@ -103,7 +138,7 @@ export class McpHost {
103138
constructor(options: McpHostOptions) {
104139
this.ui = options.ui;
105140
this.provider = options.provider;
106-
this.legacy = options.legacy ?? false;
141+
this.versionOptions = resolveVersionOptions(options.legacy ?? false, options.protocolVersion);
107142
this.oauthCallbackPort = options.oauthCallbackPort;
108143
this.roots = (options.roots ?? [process.cwd()]).map(root => path.resolve(root));
109144
}
@@ -309,7 +344,7 @@ export class McpHost {
309344

310345
private buildClient(name: string): Client {
311346
const client = new Client(CLIENT_INFO, {
312-
versionNegotiation: { mode: this.legacy ? 'legacy' : 'auto' },
347+
...this.versionOptions,
313348
capabilities: {
314349
// Both elicitation modes are declared because the handler below implements both.
315350
elicitation: { form: {}, url: {} },

examples/cli-client/test/host.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
22

33
import { configFromTargets, interpolateEnv, isHttpServer, parseConfig } from '../host/config';
44
import { contentBlockToParts, resourceToContextText, stripAnsi, toolResultToParts, truncate } from '../host/content';
5+
import { resolveVersionOptions } from '../host/host';
56
import { namespaceTool, routeNamespacedTool, sanitizeServerName } from '../host/naming';
67

78
describe('tool namespacing and routing', () => {
@@ -94,3 +95,25 @@ describe('config parsing', () => {
9495
expect(() => configFromTargets([])).toThrow();
9596
});
9697
});
98+
99+
describe('protocol version selection', () => {
100+
it('defaults to auto probing, --legacy to the plain 2025 handshake', () => {
101+
expect(resolveVersionOptions(false)).toEqual({ versionNegotiation: { mode: 'auto' } });
102+
expect(resolveVersionOptions(true)).toEqual({ versionNegotiation: { mode: 'legacy' } });
103+
});
104+
105+
it('runs a known 2025-era revision through the legacy handshake, offering only that revision', () => {
106+
const expected = { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: ['2025-06-18'] };
107+
expect(resolveVersionOptions(false, '2025-06-18')).toEqual(expected);
108+
// --legacy alongside a 2025-era revision is redundant but consistent.
109+
expect(resolveVersionOptions(true, '2025-06-18')).toEqual(expected);
110+
});
111+
112+
it('pins anything newer via the modern handshake', () => {
113+
expect(resolveVersionOptions(false, '2026-07-28')).toEqual({ versionNegotiation: { mode: { pin: '2026-07-28' } } });
114+
});
115+
116+
it('rejects --legacy combined with a revision the 2025 handshake cannot reach', () => {
117+
expect(() => resolveVersionOptions(true, '2026-07-28')).toThrow(/--legacy conflicts with --protocol-version/);
118+
});
119+
});

0 commit comments

Comments
 (0)