Merge pull request #2372 from modelcontextprotocol/v2/feat/2208-protocol-era-flag

feat: add --protocol-era launch flag for ad-hoc servers
This commit is contained in:
Cliff Hall 2026-09-14 01:32:34 -04:00 • committed by GitHub
commit 634b243994
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 366 additions and 32 deletions

View file

@ -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)

View file

@ -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,
@ -227,6 +235,70 @@ 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) => {
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();

View file

@ -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<ParseResult> {
return n;
},
)
.option(
"--protocol-era <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 <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<ParseResult> {
verify?: boolean;
cursor?: string;
connectTimeout?: number;
protocolEra?: ServerProtocolEra;
format?: OutputFormat;
toolArgsJson?: string;
clientConfig?: string;
@ -1017,6 +1025,9 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
// `--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<string, string> | 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

View file

@ -25,8 +25,11 @@ editable (see [specification/v2_catalog_launch_config.md](../../specification/v2
| `mcp-inspector --web --server-url <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 <legacy|auto|modern>` 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

View file

@ -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)

View file

@ -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<void> {
parseHeaderPair,
{},
)
.option(
"--protocol-era <era>",
"Protocol era to negotiate: legacy, auto, or modern (overrides the file's protocolEra; default legacy)",
parseProtocolEra,
)
.option(
"--client-id <id>",
"OAuth client ID (static client) for HTTP servers",
@ -78,6 +85,7 @@ export async function runTui(args?: string[]): Promise<void> {
e?: Record<string, string>;
cwd?: string;
header?: Record<string, string>;
protocolEra?: ServerProtocolEra;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
@ -95,6 +103,7 @@ export async function runTui(args?: string[]): Promise<void> {
cwd: options.cwd?.trim() || undefined,
env: options.e,
headers: options.header,
protocolEra: options.protocolEra,
transport: options.transport,
serverUrl: options.serverUrl?.trim() || undefined,
};

View file

@ -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<number> {
parseHeaderPair,
{},
)
.option(
"--protocol-era <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<number> {
serverUrl?: string;
cwd?: string;
header?: Record<string, string>;
protocolEra?: ServerProtocolEra;
dev?: boolean;
};
@ -124,6 +132,10 @@ export async function runWeb(argv: string[]): Promise<number> {
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<number> {
? "--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<number> {
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<number> {
"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;

View file

@ -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({

View file

@ -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"],

View file

@ -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",