Add --protocol-version flag to cli-client example (#2406)

This commit is contained in:
Felix Weinberger 2026-07-02 13:12:49 +01:00 • committed by GitHub
commit eacf2e5dbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 29 deletions

View file

@ -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 <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> 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 <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> 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 <v> 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

View file

@ -24,14 +24,15 @@ import type { LLMProvider } from './providers/provider';
import { ScriptedProvider } from './providers/scripted';
const USAGE = `usage: tsx cli.ts [options]
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> 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 <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> 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 <v> 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));

View file

@ -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: {} },

View file

@ -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/);
});
});