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:
cliffhall 2026-09-14 00:11:56 -04:00
commit 55db703196
3 changed files with 324 additions and 30 deletions

View file

@ -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`;

View file

@ -1,6 +1,7 @@
export {
parseKeyValuePair,
parseHeaderPair,
parseProtocolEra,
withDefaultCatalogPath,
resolveServerConfigs,
getNamedServerConfigs,

View file

@ -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),
},
};
}