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.