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 <legacy|auto|modern> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
3e728a3b8c
commit
55db703196
14 changed files with 324 additions and 30 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export {
|
||||
parseKeyValuePair,
|
||||
parseHeaderPair,
|
||||
parseProtocolEra,
|
||||
withDefaultCatalogPath,
|
||||
resolveServerConfigs,
|
||||
getNamedServerConfigs,
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
/** `--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<string, string>,
|
||||
overrides: Pick<ServerLoadOptions, "headers" | "protocolEra">,
|
||||
): 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <path>` | Working directory for a stdio server process | |
|
||||
| `-e <KEY=VALUE>` | 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 <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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue