inspector/core/mcp/node/servers.ts
cliffhall 55db703196 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>
2026-09-14 00:11:56 -04:00

204 lines
6.9 KiB
TypeScript

import type { SecretStore } from "../../auth/node/secret-store.js";
import { defaultSecretStore } from "../../auth/node/secret-store-selection.js";
import type {
InspectorServerSettings,
MCPServerConfig,
ServerProtocolEra,
} from "../types.js";
import {
DEFAULT_CONNECTION_TIMEOUT_MS,
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
} from "../types.js";
import { mcpConfigToServerEntries } from "../serverList.js";
import {
applyOverrides,
resolveServerConfigs,
resolveServerSource,
serverSourceConflict,
readServerListFile,
hasAdHocServerOptions,
withDefaultCatalogPath,
type ServerConfigOptions,
} from "./config.js";
import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
/**
* A server resolved from a catalog/config file or an ad-hoc target, paired with
* the per-server settings (headers, timeouts, OAuth, etc.) lifted from the file.
* `config` is the SDK-facing transport config; `settings` is the v2
* `InspectorServerSettings` model (post-#1358 top-level shape).
*/
export type ResolvedServer = {
config: MCPServerConfig;
settings?: InspectorServerSettings;
};
/** 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;
};
/**
* Build an `InspectorServerSettings` carrying only `--header` overrides (all
* other fields defaulted). Returns undefined when no headers are given so it can
* be used as a no-op base in `mergeSettings`.
*/
export function headersToServerSettings(
headers?: Record<string, string>,
): InspectorServerSettings | undefined {
if (!headers || Object.keys(headers).length === 0) {
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,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
autoRefreshOnListChanged: false,
paginatedLists: false,
roots: [],
};
}
/**
* 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,
overrides: Pick<ServerLoadOptions, "headers" | "protocolEra">,
): InspectorServerSettings | undefined {
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;
}
/**
* Resolve every server from a catalog/config file (or a single ad-hoc target)
* into `{ config, settings }`, lifting disk-level headers/timeouts/OAuth into
* `InspectorServerSettings` via `mcpConfigToServerEntries`. Shared by the CLI
* and TUI so both apply the same file→settings resolution (issue #1482).
*
* Applies the default writable catalog when no source/ad-hoc target is given,
* enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty
* writable catalog on first run (a missing read-only `--config` still errors).
*/
export async function loadServerEntries(
serverOptions: ServerLoadOptions,
): Promise<Record<string, ResolvedServer>> {
serverOptions = withDefaultCatalogPath(serverOptions);
const conflict = serverSourceConflict({
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
hasConfig: Boolean(serverOptions.configPath?.trim()),
hasAdHoc: hasAdHocServerOptions(serverOptions),
});
if (conflict) {
throw new Error(conflict);
}
const source = resolveServerSource(serverOptions);
if (source) {
let config = readServerListFile(source.path, source.writable);
const secretStore = serverOptions.secretStore ?? defaultSecretStore();
config = await rehydrateMcpConfigFromKeychain(config, secretStore);
const entries = mcpConfigToServerEntries(config);
const result: Record<string, ResolvedServer> = {};
for (const entry of entries) {
result[entry.name] = {
config: applyOverrides(entry.config, {
env: serverOptions.env,
cwd: serverOptions.cwd,
}),
// 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;
}
const configs = resolveServerConfigs(serverOptions, "multi");
if (configs.length === 0) {
throw new Error(
"At least one server is required. Use --catalog/--config <path> or an ad-hoc target (command/URL).",
);
}
return {
default: {
config: configs[0]!,
settings: mergeSettings(undefined, serverOptions),
},
};
}
/**
* Pick a single resolved server for runners that connect to exactly one (the
* CLI). With `serverName`, returns that entry or errors listing the available
* names; otherwise requires exactly one server in the source.
*
* The unknown-name error is source-agnostic ("not found" rather than "not found
* in config file") because `entries` may come from a file *or* a single ad-hoc
* target — e.g. `--server foo` alongside a positional command resolves to just
* `{ default }`, where "in config file" would be misleading.
*/
export function selectServerEntry(
entries: Record<string, ResolvedServer>,
serverName?: string,
): ResolvedServer {
const names = Object.keys(entries);
if (serverName) {
const entry = entries[serverName];
if (!entry) {
throw new Error(
`Server '${serverName}' not found. Available servers: ${names.join(", ")}`,
);
}
return entry;
}
if (names.length === 0) {
throw new Error("No servers found in config file");
}
if (names.length > 1) {
throw new Error(
`Multiple servers found in config file. Please specify one with --server. Available servers: ${names.join(", ")}`,
);
}
return entries[names[0]!]!;
}