From 55db703196ac58848c5c8b47edc214715f61ae15 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:11:56 -0400 Subject: [PATCH 1/2] feat: add --protocol-era launch flag for ad-hoc servers (#2208) An ad-hoc --server-url / command launch always negotiated the legacy era, because protocolEra was only settable per server in mcp.json. A wrapper that mints a short-lived bearer token therefore had to write that token to disk to exercise a server under auto or modern. --protocol-era now feeds settings.protocolEra in all three clients through a shared parseProtocolEra coercer: - CLI/TUI overlay it in loadServerEntries alongside --header, onto an ad-hoc target and onto a --catalog/--config entry for that run. - Web stamps it on the in-memory ad-hoc entry, which the backend lifts into settings like headers; as with --header it is rejected alongside --catalog/--config, but unlike --header it applies to stdio too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x Signed-off-by: cliffhall --- clients/cli/README.md | 2 +- .../__tests__/programmatic-ergonomics.test.ts | 32 +++++++ clients/cli/src/cli.ts | 11 +++ clients/launcher/README.md | 7 +- clients/tui/README.md | 2 +- clients/tui/tui.tsx | 9 ++ clients/web/server/run-web.ts | 42 +++++++-- .../web/src/test/core/mcp/node/config.test.ts | 13 +++ .../src/test/core/mcp/node/servers.test.ts | 68 +++++++++++++++ .../test/integration/server/run-web.test.ts | 87 +++++++++++++++++++ core/mcp/node/config.ts | 17 +++- core/mcp/node/index.ts | 1 + core/mcp/node/servers.ts | 60 +++++++++---- docs/mcp-server-configuration.md | 3 +- 14 files changed, 324 insertions(+), 30 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 175c8937..bd59c5d8 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -102,7 +102,7 @@ Because undici's `Response` is a different class from `globalThis.Response`, the ### MCP server (which server to connect to) -Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. +Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc run negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. ### CLI-specific (what to invoke) diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 88f42f10..1cef354d 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -227,6 +227,38 @@ describe("CLI --stored-auth-only wiring", () => { }); }); +describe("--protocol-era", () => { + it.each(["legacy", "auto"])( + "accepts %s on an ad-hoc target and connects", + async (era) => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--protocol-era", + era, + "--method", + "tools/list", + ]); + expectCliSuccess(result); + }, + ); + + it("rejects an unknown era before connecting", async () => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--protocol-era", + "future", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toContain("Invalid protocol era: future"); + }); +}); + describe("MCP_CATALOG_PATH with an ad-hoc target", () => { it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => { const { command, args } = getTestMcpServerCommand(); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 990267d0..43a5c7da 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -5,6 +5,7 @@ import type { InspectorServerSettings, MCPServerConfig, InspectorClientEnvironment, + ServerProtocolEra, } from "@inspector/core/mcp/types.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { @@ -23,6 +24,7 @@ import { selectServerEntry, parseKeyValuePair as parseEnvPair, parseHeaderPair, + parseProtocolEra, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js"; @@ -773,6 +775,11 @@ async function parseArgs(argv?: string[]): Promise { return n; }, ) + .option( + "--protocol-era ", + "Protocol era to negotiate: legacy, auto, or modern. Overrides the file-level protocolEra for --catalog/--config runs; ad-hoc --server-url / target runs otherwise default to legacy.", + parseProtocolEra, + ) .option( "--format ", "Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).", @@ -870,6 +877,7 @@ async function parseArgs(argv?: string[]): Promise { verify?: boolean; cursor?: string; connectTimeout?: number; + protocolEra?: ServerProtocolEra; format?: OutputFormat; toolArgsJson?: string; clientConfig?: string; @@ -1017,6 +1025,9 @@ async function parseArgs(argv?: string[]): Promise { // `--header` is merged into the resolved server's settings (overriding any // file-level headers); file timeouts/OAuth are preserved. See #1482. headers: options.header as Record | undefined, + // `--protocol-era` feeds `settings.protocolEra` the same way, so an ad-hoc + // launch can pick a non-legacy era without an mcp.json entry (#2208). + protocolEra: options.protocolEra, }; // Catalog list / show — no MCP connection. Run before stored-auth refresh so diff --git a/clients/launcher/README.md b/clients/launcher/README.md index e69cf0cb..1f4409c1 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -25,8 +25,11 @@ editable (see [specification/v2_catalog_launch_config.md](../../specification/v2 | `mcp-inspector --web --server-url --transport http --header "Name: Value"` (or a positional command) | One ad-hoc server held in memory, connectable with the given `--header`s | No | Rules: `--catalog` and `--config` are mutually exclusive; neither combines with -an ad-hoc target or `--header`; `--header` requires an ad-hoc HTTP/SSE server -and is applied to that connection (it is no longer a warn-only no-op). +an ad-hoc target, `--header` or `--protocol-era`; `--header` requires an ad-hoc +HTTP/SSE server and is applied to that connection (it is no longer a warn-only +no-op). `--protocol-era ` requires an ad-hoc server of any +transport and sets the era that connection negotiates, so a non-legacy era +needs no `mcp.json` entry. **Seed contents are web-specific.** When the web backend creates a missing writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a diff --git a/clients/tui/README.md b/clients/tui/README.md index 17fdb0c9..b604ad23 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -29,7 +29,7 @@ npx @modelcontextprotocol/inspector --tui --config mcp.json # read-only sessi ### MCP server (which server(s) to connect to) -Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. +Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc server negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. ### TUI-specific (OAuth for HTTP servers) diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index 3169a790..17686148 100644 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -5,7 +5,9 @@ import { render } from "ink"; import { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, } from "@inspector/core/mcp/node/index.js"; +import type { ServerProtocolEra } from "@inspector/core/mcp/types.js"; import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; import { parseRunnerOAuthCallbackUrl, @@ -41,6 +43,11 @@ export async function runTui(args?: string[]): Promise { parseHeaderPair, {}, ) + .option( + "--protocol-era ", + "Protocol era to negotiate: legacy, auto, or modern (overrides the file's protocolEra; default legacy)", + parseProtocolEra, + ) .option( "--client-id ", "OAuth client ID (static client) for HTTP servers", @@ -78,6 +85,7 @@ export async function runTui(args?: string[]): Promise { e?: Record; cwd?: string; header?: Record; + protocolEra?: ServerProtocolEra; clientId?: string; clientSecret?: string; clientMetadataUrl?: string; @@ -95,6 +103,7 @@ export async function runTui(args?: string[]): Promise { cwd: options.cwd?.trim() || undefined, env: options.e, headers: options.header, + protocolEra: options.protocolEra, transport: options.transport, serverUrl: options.serverUrl?.trim() || undefined, }; diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index f1ed5a11..a76791fa 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -10,12 +10,14 @@ import { Command } from "commander"; import type { MCPConfig, MCPServerConfig, + ServerProtocolEra, StoredMCPServer, } from "../../../core/mcp/types.ts"; import { resolveServerConfigs, parseKeyValuePair, parseHeaderPair, + parseProtocolEra, type ServerConfigOptions, } from "../../../core/mcp/node/config.ts"; import { @@ -89,6 +91,11 @@ export async function runWeb(argv: string[]): Promise { parseHeaderPair, {}, ) + .option( + "--protocol-era ", + "protocol era for the ad-hoc server: legacy, auto, or modern (default legacy)", + parseProtocolEra, + ) .option("--dev", "run in development mode (Vite)") .parse(preArgs); @@ -101,6 +108,7 @@ export async function runWeb(argv: string[]): Promise { serverUrl?: string; cwd?: string; header?: Record; + protocolEra?: ServerProtocolEra; dev?: boolean; }; @@ -124,6 +132,10 @@ export async function runWeb(argv: string[]): Promise { const hasCatalog = !!catalogPath; const hasConfig = !!opts.config; const hasHeaders = !!opts.header && Object.keys(opts.header).length > 0; + // Like `--header`, web applies `--protocol-era` only to an ad-hoc server: a + // catalog/config entry carries its own `protocolEra`, editable in Server + // Settings, and web never overlays launch flags onto a file's settings. + const hasProtocolEra = opts.protocolEra !== undefined; const hasAdHocServer = target.length > 0 || !!opts.serverUrl || @@ -137,11 +149,15 @@ export async function runWeb(argv: string[]): Promise { ? "--catalog cannot be combined with an ad-hoc server URL/command." : hasCatalog && hasHeaders ? "--header cannot be combined with --catalog. Set per-server headers in the catalog file." - : hasConfig && hasAdHocServer - ? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file." - : hasConfig && hasHeaders - ? "--header cannot be combined with --config. Set per-server headers inside the config file instead." - : null; + : hasCatalog && hasProtocolEra + ? "--protocol-era cannot be combined with --catalog. Set protocolEra per server in the catalog file." + : hasConfig && hasAdHocServer + ? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file." + : hasConfig && hasHeaders + ? "--header cannot be combined with --config. Set per-server headers inside the config file instead." + : hasConfig && hasProtocolEra + ? "--protocol-era cannot be combined with --config. Set protocolEra per server inside the config file instead." + : null; if (conflict) { console.error(`Error: ${conflict}`); process.exit(1); @@ -217,13 +233,18 @@ export async function runWeb(argv: string[]): Promise { initialMcpConfig = config; - // In-memory session entry: the SDK config plus the flat `headers` record, - // which the backend lifts into `settings.headers` on read. Nothing is - // written to disk — the ad-hoc server is a read-only session list. + // In-memory session entry: the SDK config plus the flat `headers` record + // and `protocolEra`, which the backend lifts into `settings` on read. + // Nothing is written to disk — the ad-hoc server is a read-only session + // list. The era is transport-agnostic, so unlike `--header` it applies to + // stdio too. const entry: StoredMCPServer = hasHeaders && config.type !== "stdio" && config.type !== undefined ? { ...config, headers: opts.header } : { ...config }; + if (opts.protocolEra !== undefined) { + entry.protocolEra = opts.protocolEra; + } initialServers = { mcpServers: { [deriveSeedServerId(config)]: entry }, }; @@ -233,6 +254,11 @@ export async function runWeb(argv: string[]): Promise { "Error: --header requires an ad-hoc --server-url or command target.", ); process.exit(1); + } else if (hasProtocolEra) { + console.error( + "Error: --protocol-era requires an ad-hoc --server-url or command target.", + ); + process.exit(1); } let webConfig: WebServerConfig; diff --git a/clients/web/src/test/core/mcp/node/config.test.ts b/clients/web/src/test/core/mcp/node/config.test.ts index 72983440..7e1e1689 100644 --- a/clients/web/src/test/core/mcp/node/config.test.ts +++ b/clients/web/src/test/core/mcp/node/config.test.ts @@ -12,6 +12,7 @@ import { join } from "node:path"; import { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, withDefaultCatalogPath, resolveServerConfigs, resolveServerSource, @@ -44,6 +45,18 @@ describe("parseKeyValuePair", () => { }); }); +describe("parseProtocolEra", () => { + it.each(["legacy", "auto", "modern"])("accepts %s", (era) => { + expect(parseProtocolEra(era)).toBe(era); + }); + + it("rejects an unknown era, naming the valid ones", () => { + expect(() => parseProtocolEra("Modern")).toThrow( + "Invalid protocol era: Modern. Valid eras are: legacy, auto, modern", + ); + }); +}); + describe("parseHeaderPair", () => { it("splits on the first colon and trims both sides", () => { expect(parseHeaderPair("X-Test: value")).toEqual({ diff --git a/clients/web/src/test/core/mcp/node/servers.test.ts b/clients/web/src/test/core/mcp/node/servers.test.ts index e03242e6..3cd372d8 100644 --- a/clients/web/src/test/core/mcp/node/servers.test.ts +++ b/clients/web/src/test/core/mcp/node/servers.test.ts @@ -234,6 +234,74 @@ describe("loadServerEntries", () => { ).rejects.toThrow(/--catalog cannot be combined/); }); + it("overrides the disk protocolEra with --protocol-era, preserving the rest", async () => { + const configPath = join(tempDir, "mcp.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + web: { + type: "streamable-http", + url: "http://x/mcp", + headers: { Authorization: "Bearer disk" }, + protocolEra: "auto", + requestTimeout: 9000, + }, + }, + }), + ); + + const servers = await loadServerEntries({ + configPath, + protocolEra: "modern", + }); + expect(servers.web?.settings?.protocolEra).toBe("modern"); + expect(servers.web?.settings?.requestTimeout).toBe(9000); + expect(servers.web?.settings?.headers).toEqual([ + { key: "Authorization", value: "Bearer disk" }, + ]); + }); + + it("applies --protocol-era to a server that has no disk settings", async () => { + const configPath = join(tempDir, "mcp.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + web: { type: "streamable-http", url: "http://x/mcp" }, + }, + }), + ); + + const servers = await loadServerEntries({ + configPath, + protocolEra: "auto", + }); + expect(servers.web?.settings).toMatchObject({ + protocolEra: "auto", + headers: [], + connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, + }); + }); + + it("applies --protocol-era and --header together to an ad-hoc URL", async () => { + const servers = await loadServerEntries({ + serverUrl: "http://x/mcp", + transport: "http", + headers: { Authorization: "Bearer t" }, + protocolEra: "modern", + }); + expect(servers.default?.settings).toMatchObject({ + protocolEra: "modern", + headers: [{ key: "Authorization", value: "Bearer t" }], + }); + }); + + it("gives an ad-hoc target with neither flag no settings (legacy default)", async () => { + const servers = await loadServerEntries({ target: ["my-server"] }); + expect(servers.default?.settings).toBeUndefined(); + }); + it("builds a single ad-hoc server from a positional target", async () => { const servers = await loadServerEntries({ target: ["my-server", "--flag"], diff --git a/clients/web/src/test/integration/server/run-web.test.ts b/clients/web/src/test/integration/server/run-web.test.ts index 65bd2340..bf32d88a 100644 --- a/clients/web/src/test/integration/server/run-web.test.ts +++ b/clients/web/src/test/integration/server/run-web.test.ts @@ -327,6 +327,83 @@ describe("runWeb", () => { expect(startHonoServer).not.toHaveBeenCalled(); }); + it("plumbs --protocol-era onto the seeded ad-hoc HTTP server", async () => { + void runWeb([ + "node", + "run-web", + "--server-url", + "https://example.com/mcp", + "--transport", + "http", + "--header", + "Authorization: Bearer x", + "--protocol-era", + "modern", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).toMatchObject({ + type: "streamable-http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer x" }, + protocolEra: "modern", + }); + }); + + it("plumbs --protocol-era onto a seeded ad-hoc stdio server", async () => { + void runWeb([ + "node", + "run-web", + "--protocol-era", + "auto", + "node", + "server.js", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).toMatchObject({ + command: "node", + args: ["server.js"], + protocolEra: "auto", + }); + }); + + it("omits protocolEra from the seeded entry when the flag is absent", async () => { + void runWeb([ + "node", + "run-web", + "https://example.com/mcp", + "--transport", + "http", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).not.toHaveProperty("protocolEra"); + }); + + it("rejects an unknown --protocol-era value", async () => { + await expect( + runWeb([ + "node", + "run-web", + "https://example.com/mcp", + "--protocol-era", + "future", + ]), + ).rejects.toThrow(); + expect(startHonoServer).not.toHaveBeenCalled(); + }); + + it("rejects --protocol-era with no ad-hoc server to attach it to", async () => { + await expect( + runWeb(["node", "run-web", "--protocol-era", "modern"]), + ).rejects.toThrow("process.exit:1"); + expect( + errorLines.some((l) => l.includes("--protocol-era requires an ad-hoc")), + ).toBe(true); + expect(startHonoServer).not.toHaveBeenCalled(); + }); + it("exits when an ad-hoc config resolves to no entries", async () => { vi.spyOn(nodeConfig, "resolveServerConfigs").mockReturnValueOnce([]); @@ -548,6 +625,16 @@ describe("runWeb", () => { argv: ["--config", "/a.json", "--header", "Authorization: Bearer x"], match: "--header cannot be combined with --config", }, + { + name: "--catalog + --protocol-era", + argv: ["--catalog", "/a.json", "--protocol-era", "modern"], + match: "--protocol-era cannot be combined with --catalog", + }, + { + name: "--config + --protocol-era", + argv: ["--config", "/a.json", "--protocol-era", "modern"], + match: "--protocol-era cannot be combined with --config", + }, ])("rejects $name", async ({ argv, match }) => { await expect(runWeb(["node", "run-web", ...argv])).rejects.toThrow( "process.exit:1", diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts index c90a9d6e..3f23913f 100644 --- a/core/mcp/node/config.ts +++ b/core/mcp/node/config.ts @@ -8,7 +8,8 @@ import type { SseServerConfig, StreamableHttpServerConfig, } from "../types.js"; -import { normalizeServerType } from "../serverList.js"; +import { isProtocolEra, normalizeServerType } from "../serverList.js"; +import type { ServerProtocolEra } from "../types.js"; import { toRecord } from "../../json/jsonUtils.js"; /** @@ -86,6 +87,20 @@ export function parseHeaderPair( return { ...previous, [key]: val }; } +/** + * Validate a `--protocol-era` value. Used as the Commander option coerce for + * all three clients, so an ad-hoc launch can pick the era without writing an + * `mcp.json` entry for it (#2208). Pure function; no Commander dependency. + */ +export function parseProtocolEra(value: string): ServerProtocolEra { + if (!isProtocolEra(value)) { + throw new Error( + `Invalid protocol era: ${value}. Valid eras are: legacy, auto, modern`, + ); + } + return value; +} + /** On-disk contents of a freshly seeded empty catalog (pretty-printed). */ const EMPTY_CATALOG_CONTENT = `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`; diff --git a/core/mcp/node/index.ts b/core/mcp/node/index.ts index c3e657f4..f8fa91fc 100644 --- a/core/mcp/node/index.ts +++ b/core/mcp/node/index.ts @@ -1,6 +1,7 @@ export { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, withDefaultCatalogPath, resolveServerConfigs, getNamedServerConfigs, diff --git a/core/mcp/node/servers.ts b/core/mcp/node/servers.ts index 85f4bdfb..8b7a2e34 100644 --- a/core/mcp/node/servers.ts +++ b/core/mcp/node/servers.ts @@ -1,6 +1,10 @@ import type { SecretStore } from "../../auth/node/secret-store.js"; import { defaultSecretStore } from "../../auth/node/secret-store-selection.js"; -import type { InspectorServerSettings, MCPServerConfig } from "../types.js"; +import type { + InspectorServerSettings, + MCPServerConfig, + ServerProtocolEra, +} from "../types.js"; import { DEFAULT_CONNECTION_TIMEOUT_MS, DEFAULT_MAX_FETCH_REQUESTS, @@ -30,10 +34,14 @@ export type ResolvedServer = { settings?: InspectorServerSettings; }; -/** Loader options: the shared source flags plus a single `--header` set that is - * broadcast into every resolved server's settings. */ +/** Loader options: the shared source flags plus a single `--header` set and an + * optional `--protocol-era`, both broadcast into every resolved server's + * settings. */ export type ServerLoadOptions = ServerConfigOptions & { headers?: Record; + /** `--protocol-era`: overrides the file's `protocolEra` (or the legacy + * default) the way `--header` overrides its headers (#2208). */ + protocolEra?: ServerProtocolEra; /** Test injection; defaults to the selected store (see * `secret-store-selection.ts`) for catalog/config loads. */ secretStore?: SecretStore; @@ -51,7 +59,15 @@ export function headersToServerSettings( return undefined; } return { + ...defaultServerSettings(), headers: Object.entries(headers).map(([key, value]) => ({ key, value })), + }; +} + +/** A settings node with every field at its product default. */ +function defaultServerSettings(): InspectorServerSettings { + return { + headers: [], env: [], metadata: {}, connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, @@ -65,18 +81,29 @@ export function headersToServerSettings( } /** - * Overlay CLI `--header` values onto the settings lifted from the file. Only the - * `headers` field is overridden — timeouts, OAuth, and the rest of the file's - * settings are preserved. + * Overlay `--header` / `--protocol-era` onto the settings lifted from the file + * (or onto nothing, for an ad-hoc target). Only those two fields are overridden + * — timeouts, OAuth, and the rest of the file's settings are preserved. Returns + * `base` untouched when neither flag was given. */ function mergeSettings( base: InspectorServerSettings | undefined, - headers?: Record, + overrides: Pick, ): InspectorServerSettings | undefined { - const fromHeaders = headersToServerSettings(headers); - if (!fromHeaders) return base; - if (!base) return fromHeaders; - return { ...base, headers: fromHeaders.headers }; + let settings = base; + const fromHeaders = headersToServerSettings(overrides.headers); + if (fromHeaders) { + settings = settings + ? { ...settings, headers: fromHeaders.headers } + : fromHeaders; + } + if (overrides.protocolEra) { + settings = { + ...(settings ?? defaultServerSettings()), + protocolEra: overrides.protocolEra, + }; + } + return settings; } /** @@ -117,10 +144,11 @@ export async function loadServerEntries( env: serverOptions.env, cwd: serverOptions.cwd, }), - // Deliberate broadcast: a single `--header` set is merged into EVERY - // server in the catalog/config (fine for the common single-server case; - // for multi-server files, prefer per-server headers in the file itself). - settings: mergeSettings(entry.settings, serverOptions.headers), + // Deliberate broadcast: a single `--header` set (and `--protocol-era`) + // is merged into EVERY server in the catalog/config (fine for the + // common single-server case; for multi-server files, prefer per-server + // settings in the file itself). + settings: mergeSettings(entry.settings, serverOptions), }; } return result; @@ -135,7 +163,7 @@ export async function loadServerEntries( return { default: { config: configs[0]!, - settings: headersToServerSettings(serverOptions.headers), + settings: mergeSettings(undefined, serverOptions), }, }; } diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index b938e53f..e1b6799c 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -11,7 +11,7 @@ Client-specific options (the web server port, the CLI method to invoke, TUI navi The two do not mix. `--catalog` and `--config` are mutually exclusive with each other, and neither combines with an ad-hoc target. All three clients apply the same **source-selection** rules — the CLI and TUI through the shared `serverSourceConflict` helper (`core/mcp/node/config.ts`), web through an equivalent inline matrix in `clients/web/server/run-web.ts`. The two implementations diverge on two narrow axes, in opposite directions: -- **Web is stricter on `--header`:** it also rejects `--header` alongside `--catalog`/`--config`, because the CLI and TUI merge `--header` into per-server settings and web does not. +- **Web is stricter on `--header` and `--protocol-era`:** it also rejects either alongside `--catalog`/`--config`, because the CLI and TUI merge them into per-server settings and web does not. - **Web is looser on `--transport stdio`:** the CLI and TUI treat _any_ `--transport` as an ad-hoc marker, so `--catalog c.json --transport stdio` is rejected as a catalog/ad-hoc conflict there; web excludes `stdio` from that test and accepts the same combination, silently ignoring the flag. ## From a file: `--catalog` vs. `--config` @@ -125,6 +125,7 @@ Without a `--` on the line the target is only the leading run of **non-dash** to | `--cwd ` | Working directory for a stdio server process | | | `-e ` | Environment variable for a stdio server; repeatable | | | `--header "Name: Value"` | HTTP header for an HTTP/SSE server; repeatable | On web, requires an ad-hoc HTTP/SSE server | +| `--protocol-era ` | `legacy`, `auto`, or `modern` — the era to negotiate | Sets [`protocolEra`](#inspector-specific-per-server-fields) without a file, on any transport. CLI/TUI also override a file's `protocolEra` with it; web, like `--header`, requires an ad-hoc target | | `[target...]` | Positional command or URL for one ad-hoc server | | **`MCP_CATALOG_PATH` and ad-hoc targets differ by client.** The **CLI** ignores the env var when an ad-hoc target is given (a positional command, `--server-url`, or `--transport`), so a shell that exports it can still run one-off ad-hoc invocations without tripping the catalog/ad-hoc conflict. **Web and TUI read it unconditionally** — with it exported, an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as `--catalog cannot be combined with an ad-hoc server URL/command`. Unset the variable for that invocation on those two surfaces. From 70cd4f05acdbe946c8dd4eca61c517c042025ea1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:59:23 -0400 Subject: [PATCH 2/2] test: discriminate --protocol-era wiring against a modern-only server (#2208) The legacy and auto e2e cases both connect to the legacy stdio fixture whether or not the flag reaches negotiation, since auto falls back. Run initialize against an HTTP test server that rejects the legacy handshake: the default must fail, and --protocol-era modern must connect and report the 2026-07-28 protocol version. Deleting the serverOptions assignment in cli.ts now fails this case (Copilot review, PR #2372). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x Signed-off-by: cliffhall --- .../__tests__/programmatic-ergonomics.test.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 1cef354d..8459644e 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect } from "vitest"; -import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { + createEchoTool, + createTestServerHttp, + createTestServerInfo, + getTestMcpServerCommand, +} from "@modelcontextprotocol/inspector-test-server"; import { runCli } from "./helpers/cli-runner.js"; import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js"; -import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import { + MODERN_PROTOCOL_VERSION, + type InspectorServerSettings, +} from "@inspector/core/mcp/types.js"; import { expectCliFailure, expectCliSuccess, @@ -228,6 +236,38 @@ describe("CLI --stored-auth-only wiring", () => { }); describe("--protocol-era", () => { + // The discriminating case: `legacy` and `auto` both connect to a legacy + // fixture whether or not the flag is wired, since `auto` falls back. A + // modern-only server rejects the default legacy handshake, so only a flag + // that actually reaches negotiation turns the failure into a modern session. + it("negotiates the modern era against a modern-only server", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + modern: { legacy: "reject" }, + }); + try { + await server.start(); + const adHoc = [server.url, "--transport", "http"]; + + const withoutFlag = await runCli([...adHoc, "--method", "initialize"]); + expectCliFailure(withoutFlag); + + const withFlag = await runCli([ + ...adHoc, + "--protocol-era", + "modern", + "--method", + "initialize", + ]); + expectCliSuccess(withFlag); + const json = JSON.parse(withFlag.stdout) as { protocolVersion: string }; + expect(json.protocolVersion).toBe(MODERN_PROTOCOL_VERSION); + } finally { + await server.stop(); + } + }); + it.each(["legacy", "auto"])( "accepts %s on an ad-hoc target and connects", async (era) => {