Add --protocol-version flag to cli-client example (#2406)
This commit is contained in:
parent
448ba0ff54
commit
eacf2e5dbe
4 changed files with 92 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue