From eacf2e5dbeb1fa65492750c15fdc55cafdf28686 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:12:49 +0100 Subject: [PATCH] Add --protocol-version flag to cli-client example (#2406) --- examples/cli-client/README.md | 19 ++++++------ examples/cli-client/cli.ts | 36 ++++++++++++---------- examples/cli-client/host/host.ts | 43 ++++++++++++++++++++++++--- examples/cli-client/test/host.test.ts | 23 ++++++++++++++ 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/examples/cli-client/README.md b/examples/cli-client/README.md index c9eca01c..c75d086e 100644 --- a/examples/cli-client/README.md +++ b/examples/cli-client/README.md @@ -48,7 +48,7 @@ pnpm --filter @mcp-examples/todos-server start:http ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --provider anthropic ``` -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, …)`). +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. A tour that touches everything, in one sitting: @@ -111,14 +111,15 @@ For a persistent setup, copy `config.example.json` to `config.json` (or pass `-- ## All flags ```text ---server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) ---config mcpServers config file (default: ./config.json, falling back to spawning todos-server) ---provider scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted) ---model pin a model id (default: the provider's latest mid-tier model) ---root workspace root exposed to servers via roots/list (repeatable; default: cwd) ---callback-port fixed loopback port for the OAuth callback (default: a free port) ---legacy use the 2025 initialize handshake instead of probing for 2026-07-28 --h, --help show usage +--server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) +--config mcpServers config file (default: ./config.json, falling back to spawning todos-server) +--provider scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted) +--model pin a model id (default: the provider's latest mid-tier model) +--root workspace root exposed to servers via roots/list (repeatable; default: cwd) +--callback-port fixed loopback port for the OAuth callback (default: a free port) +--legacy use the 2025 initialize handshake instead of probing for 2026-07-28 +--protocol-version negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin +-h, --help show usage ``` ## How this example is tested diff --git a/examples/cli-client/cli.ts b/examples/cli-client/cli.ts index 84b13df8..5b29c5a8 100644 --- a/examples/cli-client/cli.ts +++ b/examples/cli-client/cli.ts @@ -24,14 +24,15 @@ import type { LLMProvider } from './providers/provider'; import { ScriptedProvider } from './providers/scripted'; const USAGE = `usage: tsx cli.ts [options] - --server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) - --config mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server) - --provider scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted) - --model pin a model id (default: the provider's latest mid-tier model) - --root workspace root exposed to servers via roots/list (repeatable; default: cwd) - --callback-port fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH) - --legacy use the 2025 initialize handshake instead of probing for 2026-07-28 - --help this help`; + --server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) + --config mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server) + --provider scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted) + --model pin a model id (default: the provider's latest mid-tier model) + --root workspace root exposed to servers via roots/list (repeatable; default: cwd) + --callback-port fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH) + --legacy use the 2025 initialize handshake instead of probing for 2026-07-28 + --protocol-version negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin + --help this help`; function pickProvider(name: string | undefined, model: string | undefined): LLMProvider { const chosen = @@ -73,6 +74,7 @@ const { values } = parseArgs({ root: { type: 'string', multiple: true }, 'callback-port': { type: 'string' }, legacy: { type: 'boolean' }, + 'protocol-version': { type: 'string' }, help: { type: 'boolean', short: 'h' } } }); @@ -111,15 +113,17 @@ for (const [serverName, entry] of Object.entries(config.mcpServers)) { ui.status(` ${serverName} → ${'url' in entry ? entry.url : [entry.command, ...(entry.args ?? [])].join(' ')}`); } -const host = new McpHost({ - ui, - provider, - roots: values.root ?? [process.cwd()], - legacy: values.legacy ?? false, - oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined -}); -hostRef.current = host; +let host: McpHost; try { + host = new McpHost({ + ui, + provider, + roots: values.root ?? [process.cwd()], + legacy: values.legacy ?? false, + protocolVersion: values['protocol-version'], + oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined + }); + hostRef.current = host; await host.connect(config); } catch (error) { ui.print(error instanceof Error ? error.message : String(error)); diff --git a/examples/cli-client/host/host.ts b/examples/cli-client/host/host.ts index 15d179b2..49165c95 100644 --- a/examples/cli-client/host/host.ts +++ b/examples/cli-client/host/host.ts @@ -10,7 +10,8 @@ import type { Prompt, Resource, ResourceTemplateType, - Tool + Tool, + VersionNegotiationOptions } from '@modelcontextprotocol/client'; import { Client, @@ -18,6 +19,7 @@ import { ProtocolError, SdkError, StreamableHTTPClientTransport, + SUPPORTED_PROTOCOL_VERSIONS, UnauthorizedError } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; @@ -61,10 +63,43 @@ export interface McpHostOptions { roots?: string[]; /** Use the 2025 `initialize` handshake instead of probing for 2026-07-28. */ legacy?: boolean; + /** + * Negotiate exactly this protocol revision: a known 2025-era value runs the legacy + * handshake offering only that revision; anything else is pinned via the modern + * handshake, which fails loudly unless the server offers it. + */ + protocolVersion?: string; /** Fixed loopback port for the OAuth callback (default: an OS-assigned free port). Useful over SSH port-forwarding. */ oauthCallbackPort?: number; } +/** The version-negotiation slice of the SDK client options every connection this host makes shares. */ +export interface VersionOptions { + versionNegotiation: VersionNegotiationOptions; + supportedProtocolVersions?: string[]; +} + +/** + * Map the era toggle and optional pinned revision onto the SDK's negotiation options. + * A known 2025-era revision runs the legacy handshake offering exactly that revision + * (the client rejects a server that answers with any other version); everything else + * becomes a modern pin, and the SDK's own typed error covers strings that are neither. + */ +export function resolveVersionOptions(legacy: boolean, protocolVersion?: string): VersionOptions { + if (protocolVersion === undefined) { + return { versionNegotiation: { mode: legacy ? 'legacy' : 'auto' } }; + } + if (SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + return { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: [protocolVersion] }; + } + if (legacy) { + throw new Error( + `--legacy conflicts with --protocol-version ${protocolVersion}: the 2025 handshake can only negotiate ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}` + ); + } + return { versionNegotiation: { mode: { pin: protocolVersion } } }; +} + function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined { if (error instanceof UnauthorizedError) return error; // Under versionNegotiation 'auto', a connect-time 401 surfaces as @@ -94,7 +129,7 @@ function samplingContentToParts(content: CreateMessageRequest['params']['message export class McpHost { private readonly ui: HostUI; private readonly provider: LLMProvider; - private readonly legacy: boolean; + private readonly versionOptions: VersionOptions; private roots: string[]; private readonly watches: McpSubscription[] = []; private readonly oauthCallbackPort?: number; @@ -103,7 +138,7 @@ export class McpHost { constructor(options: McpHostOptions) { this.ui = options.ui; this.provider = options.provider; - this.legacy = options.legacy ?? false; + this.versionOptions = resolveVersionOptions(options.legacy ?? false, options.protocolVersion); this.oauthCallbackPort = options.oauthCallbackPort; this.roots = (options.roots ?? [process.cwd()]).map(root => path.resolve(root)); } @@ -309,7 +344,7 @@ export class McpHost { private buildClient(name: string): Client { const client = new Client(CLIENT_INFO, { - versionNegotiation: { mode: this.legacy ? 'legacy' : 'auto' }, + ...this.versionOptions, capabilities: { // Both elicitation modes are declared because the handler below implements both. elicitation: { form: {}, url: {} }, diff --git a/examples/cli-client/test/host.test.ts b/examples/cli-client/test/host.test.ts index 428b79a8..8ff28d1e 100644 --- a/examples/cli-client/test/host.test.ts +++ b/examples/cli-client/test/host.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { configFromTargets, interpolateEnv, isHttpServer, parseConfig } from '../host/config'; import { contentBlockToParts, resourceToContextText, stripAnsi, toolResultToParts, truncate } from '../host/content'; +import { resolveVersionOptions } from '../host/host'; import { namespaceTool, routeNamespacedTool, sanitizeServerName } from '../host/naming'; describe('tool namespacing and routing', () => { @@ -94,3 +95,25 @@ describe('config parsing', () => { expect(() => configFromTargets([])).toThrow(); }); }); + +describe('protocol version selection', () => { + it('defaults to auto probing, --legacy to the plain 2025 handshake', () => { + expect(resolveVersionOptions(false)).toEqual({ versionNegotiation: { mode: 'auto' } }); + expect(resolveVersionOptions(true)).toEqual({ versionNegotiation: { mode: 'legacy' } }); + }); + + it('runs a known 2025-era revision through the legacy handshake, offering only that revision', () => { + const expected = { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: ['2025-06-18'] }; + expect(resolveVersionOptions(false, '2025-06-18')).toEqual(expected); + // --legacy alongside a 2025-era revision is redundant but consistent. + expect(resolveVersionOptions(true, '2025-06-18')).toEqual(expected); + }); + + it('pins anything newer via the modern handshake', () => { + expect(resolveVersionOptions(false, '2026-07-28')).toEqual({ versionNegotiation: { mode: { pin: '2026-07-28' } } }); + }); + + it('rejects --legacy combined with a revision the 2025 handshake cannot reach', () => { + expect(() => resolveVersionOptions(true, '2026-07-28')).toThrow(/--legacy conflicts with --protocol-version/); + }); +});