inspector/core/mcp/serverList.ts
cliffhall eb41185c37 feat: make the skills verification catalog budget configurable per server (#2294)
Adds a Skills section to Server Settings with Maximum Number of Skills and
Maximum Catalog Size fields, defaulting to SKILL_MAX_CATALOG_SKILLS (256) and
SKILL_MAX_CATALOG_BYTES (64 MiB). The values persist as skillCatalogMaxSkills /
skillCatalogMaxBytes on the server's mcp.json entry (omitted at the default),
are validated as positive integers by the /api/servers routes, and are read by
verifySkills through getServerSettings(), so the CLI's --verify and the TUI
Skills pane both honor them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 23:54:29 -04:00

1153 lines
47 KiB
TypeScript

/**
* Pure converters between the on-disk `mcp.json` shape (`MCPConfig`) and the
* in-memory list of `ServerEntry` records the UI consumes. No I/O, no Node
* deps — safe to import from the browser side of core/ as well as the
* remote-server route handlers.
*/
import {
DEFAULT_CONNECTION_TIMEOUT_MS,
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_MODERN_LOG_LEVEL,
DEFAULT_PROTOCOL_ERA,
DEFAULT_TASK_TTL_MS,
isModernLogLevel,
} from "./types.js";
import {
isSkillCatalogLimit,
SKILL_MAX_CATALOG_BYTES,
SKILL_MAX_CATALOG_SKILLS,
} from "./skills.js";
import type { Root } from "@modelcontextprotocol/client";
import type {
InspectorServerSettings,
RequestMetadata,
MCPConfig,
MCPServerConfig,
ServerEntry,
ServerProtocolEra,
ServerType,
StdioServerConfig,
StoredMCPServer,
} from "./types.js";
import {
SECRET_FIELD_OAUTH_CLIENT_SECRET,
envSecretField,
} from "../auth/secret-fields.js";
import { toRecord } from "../json/jsonUtils.js";
import type { JsonValue } from "../json/jsonUtils.js";
import { isSerializableJson } from "../json/jsonUtils.js";
// The full set of valid `type` discriminator values, used to reject anything
// else read off disk so unknown strings can't propagate to narrowing sites.
const VALID_SERVER_TYPES: ReadonlySet<ServerType> = new Set([
"stdio",
"sse",
"streamable-http",
]);
const VALID_PROTOCOL_ERAS: ReadonlySet<ServerProtocolEra> = new Set([
"legacy",
"auto",
"modern",
]);
/**
* Runtime guard for the `protocolEra` literal. `StoredMCPServer` types the
* field as `ServerProtocolEra`, but a hand-edited `mcp.json` read directly by
* the CLI/TUI (which don't pass through the `/api/servers` route validators)
* can carry any string. Dropping unknowns here keeps a garbage value from
* reaching `eraToVersionNegotiation` — matching the read-side check the
* `/api/servers` route already applies.
*/
export function isProtocolEra(value: unknown): value is ServerProtocolEra {
return (
typeof value === "string" &&
VALID_PROTOCOL_ERAS.has(value as ServerProtocolEra)
);
}
/**
* Normalizes server type:
* - missing / unknown / non-string → "stdio" (matches Claude Desktop's default)
* - "http" → "streamable-http" (legacy alias)
* - valid ServerType → passed through unchanged
*
* Lives here (rather than in node/config.ts) so the file stays Node-free
* and the same normalization is applied by every consumer of `mcp.json`.
* The "unknown → stdio" branch keeps a hand-edited file with `"type":"websocket"`
* or `"type": 42` from leaking through `as ServerType` casts into narrowing
* sites that would then fall through in surprising ways.
*/
export function normalizeServerType(
config: Record<string, unknown> & { type?: unknown },
): MCPServerConfig {
const type = config.type;
let normalizedType: ServerType;
if (typeof type !== "string") {
normalizedType = "stdio";
} else if (type === "http") {
normalizedType = "streamable-http";
} else if (VALID_SERVER_TYPES.has(type as ServerType)) {
normalizedType = type as ServerType;
} else {
normalizedType = "stdio";
}
return { ...config, type: normalizedType } as MCPServerConfig;
}
/**
* The shared roots normalizer — for a list from the web settings form, from
* `mcp.json`, or from `setRoots()`. Puts them in the shape the Inspector
* advertises and persists: drop entries whose `uri` is blank (the settings form
* leaves a new row empty mid-edit) and drop a blank/whitespace `name`. Any other
* fields a root carries (e.g. `_meta` from a hand-edited `mcp.json`) are
* preserved — only `uri`/`name` are normalized. Every path roots take runs
* through here — the settings → disk converter
* (`inspectorSettingsToStoredFields`), the `InspectorClient` constructor and
* `setRoots`, and all three clients' connect-time wiring — so what the server is
* told matches what hits disk.
*
* `Root[]` is a compile-time type over hand-editable `mcp.json`, and every
* client now feeds this straight from disk (#1797), so the shape is validated
* at runtime too, rather than throwing at connect: a non-array bails to `[]`,
* an entry without a string `uri` is dropped, and a non-string `name` is
* dropped from an otherwise-usable entry. Each case warns.
*/
export function cleanRoots(roots: Root[]): Root[] {
// Keep: the `Root[]` parameter narrows this branch to `never`, but the type is
// a promise hand-edited `mcp.json` does not keep. Not dead code (#1797).
if (!Array.isArray(roots)) {
console.warn("Ignoring `roots`: expected an array, got", typeof roots);
return [];
}
return roots
.filter((r) => {
// Keep: unreachable per the parameter type, reachable from disk.
if (typeof r?.uri !== "string") {
console.warn("Dropping root without a string `uri`:", r);
return false;
}
return r.uri.trim() !== "";
})
.map((r) => {
// `?.` would guard null/undefined but not a non-string `name` from disk,
// which `.trim()` throws on — the `uri` case above, one field over.
const rawName = r.name;
if (rawName !== undefined && typeof rawName !== "string") {
console.warn("Dropping non-string `name` on root:", r);
}
const trimmedName =
typeof rawName === "string" ? rawName.trim() : undefined;
// Strip `name` off the carried-through rest so a cleared optional name
// doesn't persist as `name: ""`; re-add it only when non-empty.
const { name: _name, ...rest } = r;
return trimmedName ? { ...rest, name: trimmedName } : rest;
});
}
/**
* The Inspector-extension fields that live as direct keys on a `StoredMCPServer`
* (post-#1358) — split out from `MCPServerConfig` so both directions of the
* converter can name them in one place. Equivalent to
* `Pick<StoredMCPServer, "headers" | "metadata" | ...>` without re-listing.
*/
type StoredInspectorFields = Pick<
StoredMCPServer,
| "headers"
| "metadata"
| "protocolEra"
| "modernLogLevel"
| "connectionTimeout"
| "requestTimeout"
| "taskTtl"
| "autoRefreshOnListChanged"
| "paginatedLists"
| "advertisedExtensions"
| "maxFetchRequests"
| "skillCatalogMaxSkills"
| "skillCatalogMaxBytes"
| "oauth"
| "roots"
>;
/**
* Convert a stored stdio `env` record into the controlled key/value rows the
* settings form edits, preserving the object's key insertion order. Empty when
* absent. Inverse of `envPairsToRecord`.
*
* The transform is generic key/value, so the OAuth authorization-parameters
* record (#2018) reuses it rather than growing a second copy.
*/
export function envRecordToPairs(
env: Record<string, string> | undefined,
): { key: string; value: string }[] {
return env ? Object.entries(env).map(([key, value]) => ({ key, value })) : [];
}
/**
* Collapse the form's controlled `env` rows back into a `Record`, dropping rows
* with an empty/whitespace key (the form lets users leave a new row blank
* mid-edit). Inverse of `envRecordToPairs`. Used by the `/api/servers` PUT
* write-through that maps `settings.env` back onto `config.env`.
*
* Generic key/value like its inverse, so the OAuth authorization-parameters
* rows (#2018) collapse through it too.
*/
export function envPairsToRecord(
pairs: { key: string; value: string }[],
): Record<string, string> {
const out: Record<string, string> = {};
for (const { key, value } of pairs) {
if (key.trim() === "") continue;
out[key] = value;
}
return out;
}
/**
* The stdio `env` / `cwd` **config** fields implied by a settings mirror.
* `undefined` means "the field is not set" — i.e. remove it — which is how a
* user clears a value through the Server Settings modal (empty env list, blank
* cwd).
*
* `env` and `cwd` are the two fields the modal edits as *settings* while the
* transport reads them off *config* (see `storedFieldsToInspectorSettings`), so
* every consumer that has settings and needs a config has to perform this same
* mapping. It lives here, once, because the two consumers are on opposite sides
* of the wire — the `/api/servers` PUT write-through persists it, and the web
* client applies it when constructing an `InspectorClient` (#2096) — and a
* disagreement between them is invisible until a spawned child process gets an
* environment the file does not describe.
*/
export function stdioConfigFieldsFromSettings(
settings: InspectorServerSettings,
): { env: Record<string, string> | undefined; cwd: string | undefined } {
const env = envPairsToRecord(settings.env);
const cwd = settings.cwd?.trim();
return {
env: Object.keys(env).length > 0 ? env : undefined,
cwd: cwd ? cwd : undefined,
};
}
/**
* A copy of `config` with the stdio `env` / `cwd` taken from `settings`.
*
* Used where a caller holds a settings value it trusts more than the config it
* was read alongside — the web client's connect path, which resolves settings
* through the last-persisted-write tracker while `config` still comes off a
* `servers` entry that stops advancing once a list read fails (#2089). Without
* this the two disagree and the child process is spawned with the *pre-save*
* environment even though the save reached disk (#2096).
*
* Non-stdio configs are returned untouched — they carry neither field, matching
* the modal's stdio-only UI. Absent settings likewise: a server with no settings
* node has nothing to apply, and treating that as an empty mirror would clear a
* config `env` the file does hold.
*/
export function applyStdioSettingsToConfig(
config: MCPServerConfig,
settings: InspectorServerSettings | undefined,
): MCPServerConfig {
if (!settings) return config;
if (!(config.type === "stdio" || config.type === undefined)) return config;
const { env, cwd } = stdioConfigFieldsFromSettings(settings);
const next: StdioServerConfig = { ...(config as StdioServerConfig) };
if (env) next.env = env;
else delete next.env;
if (cwd) next.cwd = cwd;
else delete next.cwd;
return next;
}
/**
* Read a server's `metadata` off disk into the in-memory `RequestMetadata`
* object, accepting both the current object shape and the pre-#1910
* `{ key, value }[]` pair array.
*
* The pair array is the shape every `mcp.json` written before #1910 carries, so
* dropping it would silently stop sending a user's configured `_meta` on the
* first read of an existing file. It is read here and never written back:
* `inspectorSettingsToStoredFields` emits only the object form, so the file is
* migrated the next time the entry is saved.
*
* Anything else — a string, a number, an array of non-pairs — is not metadata
* the Inspector can send, so it is dropped with a warning rather than half-
* interpreted, following `cleanRoots` / `cleanAuthorizationParams` above. A
* pair whose key is blank is skipped for the same reason the write side omits
* it: `_meta` has no meaningful empty key.
*/
export function normalizeStoredMetadata(
metadata: StoredMCPServer["metadata"] | unknown,
): RequestMetadata {
if (metadata === undefined || metadata === null) return {};
if (Array.isArray(metadata)) {
const out: RequestMetadata = {};
for (const entry of metadata) {
if (
typeof entry !== "object" ||
entry === null ||
typeof (entry as { key?: unknown }).key !== "string"
) {
// Structural message only — never the entry. A malformed legacy row
// still holds whatever the user put in it, which for `_meta` can be a
// credential, and this goes to the console of every client that reads
// the catalog.
console.warn(
"Ignoring malformed legacy metadata entry (expected `{ key, value }`).",
);
continue;
}
const { key, value } = entry as { key: string; value?: JsonValue };
if (key.trim() === "") continue;
// `??`, not `||`: `null` is a legal `_meta` value and must survive the
// migration as `null`. Only a genuinely absent `value` becomes `""`.
const resolved = value === undefined ? "" : value;
if (!keepSerializable(key, resolved)) continue;
defineMetadataKey(out, key, resolved);
}
return out;
}
if (typeof metadata === "object") {
// A hand-edited catalog reaches this untouched by any editor, so it is a
// real source of values that parse but cannot be sent — `1e400` becomes
// `Infinity`, which the next serialization writes as `null`. Same
// invariant the web editor and the CLI enforce, applied per key so one bad
// value does not cost the rest.
const out: RequestMetadata = {};
for (const [key, value] of Object.entries(metadata as RequestMetadata)) {
if (keepSerializable(key, value)) defineMetadataKey(out, key, value);
}
return out;
}
console.warn(
`Ignoring malformed \`metadata\` (expected a JSON object, got ${describeJsonType(metadata)}).`,
);
return {};
}
/**
* Store a metadata key as an **own** property.
*
* `out[key] = value` invokes the prototype setter for the key `"__proto__"`,
* so the entry vanishes on reload and — for an object value — the returned
* metadata is given a caller-controlled prototype. `_meta` keys come from a
* file a user (or something that wrote that file) controls, so `"__proto__"`
* is a key that can genuinely arrive.
*
* Both branches of `normalizeStoredMetadata` go through this rather than each
* spelling it out. The object branch was originally a spread — safe, because
* spread defines rather than sets — and rewriting it into a per-key loop for
* value filtering silently reintroduced the hazard the legacy branch had
* already fixed. One mechanism is what stops that recurring.
*/
function defineMetadataKey(
out: RequestMetadata,
key: string,
value: JsonValue,
): void {
Object.defineProperty(out, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
/**
* Whether a metadata value can be sent as written, warning by key when it
* cannot.
*
* Names the key and the offending type, never the value: `_meta` carries
* whatever the user configured, credentials included, and these warnings reach
* ordinary console output.
*/
function keepSerializable(key: string, value: unknown): boolean {
if (isSerializableJson(value)) return true;
console.warn(
`Ignoring metadata key "${key}" — its value is not JSON that can be sent ` +
`(got ${describeJsonType(value)}; note \`1e400\` parses to Infinity).`,
);
return false;
}
/** A one-word type name for a diagnostic that must not disclose the value. */
function describeJsonType(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (typeof value === "number" && !Number.isFinite(value)) {
return Number.isNaN(value) ? "NaN" : "a non-finite number";
}
return typeof value;
}
/**
* Validate a server's `oauth.authorizationParams` as it comes off disk, or
* `undefined` when there is nothing usable. (#2018)
*
* `StoredMCPServer` types this as `Record<string, string>`, but that is a
* compile-time promise a hand-edited `mcp.json` does not keep — and only the web
* client's `/api/servers` route checks it, while the CLI and TUI read the file
* directly. Without this, `authorizationParams: "oops"` enumerates as the
* character-indexed pairs `0=o, 1=o, …` and `{ audience: 5 }` reaches
* `URLSearchParams.set`, which stringifies it — either way the Inspector sends
* query parameters the user never wrote. Each rejection warns, following
* `cleanRoots` above, which guards the same class for `roots`.
*/
export function cleanAuthorizationParams(
params: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (params === undefined) return undefined;
// Keep: unreachable per the parameter type, reachable from disk.
if (typeof params !== "object" || params === null || Array.isArray(params)) {
console.warn(
"Ignoring `oauth.authorizationParams`: expected an object of string values, got",
Array.isArray(params) ? "array" : typeof params,
);
return undefined;
}
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value !== "string") {
console.warn(
`Dropping \`oauth.authorizationParams.${key}\`: expected a string value, got ${typeof value}.`,
);
continue;
}
if (key.trim() === "") continue;
out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Collapse a server's custom authorization-parameter rows into the record the
* `InspectorClientOptions.oauth.authorizationParams` option takes, or
* `undefined` when nothing survives (no rows, or every row blank-keyed). Shared
* by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths so both
* derive the option identically. (#2018)
*/
export function oauthAuthorizationParamsFromSettings(
settings: Pick<InspectorServerSettings, "oauthAuthorizationParams">,
): Record<string, string> | undefined {
if (!settings.oauthAuthorizationParams) return undefined;
const record = envPairsToRecord(settings.oauthAuthorizationParams);
return Object.keys(record).length > 0 ? record : undefined;
}
/**
* Validate one of a server's `oauth` endpoint overrides as it comes off disk,
* or `undefined` when there is nothing usable. (#1906)
*
* Same reasoning as `cleanAuthorizationParams` above: `StoredMCPServer` types
* these as `string`, but that is a compile-time promise a hand-edited file does
* not keep, and the CLI/TUI read `mcp.json` directly. A non-string reaches
* `oauthEndpointOverridesFromSettings`, which calls `.trim()` on it — so without
* this the whole server list fails to load. URL *validity* is not checked here;
* that happens where the override is applied, so a typo drops one field with a
* warning instead of anything louder.
*/
export function cleanEndpointOverride(
value: string | undefined,
field: "authorizationUrl" | "tokenUrl",
): string | undefined {
if (value === undefined) return undefined;
// Keep: unreachable per the parameter type, reachable from disk.
if (typeof value !== "string") {
console.warn(
`Ignoring \`oauth.${field}\`: expected a string, got ${typeof value}.`,
);
return undefined;
}
return value.trim() || undefined;
}
/**
* Collapse a server's endpoint-override fields into the
* `InspectorClientOptions.oauth` sub-shape, or `undefined` when neither is set.
* Shared by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths
* so both derive the option identically. The values are passed through as
* typed; validation (absolute http(s) URL) happens once at the point of use, in
* `core/auth/endpointOverrides.ts`. (#1906)
*/
export function oauthEndpointOverridesFromSettings(
settings: Pick<
InspectorServerSettings,
"oauthAuthorizationUrl" | "oauthTokenUrl"
>,
): { authorizationUrl?: string; tokenUrl?: string } | undefined {
const authorizationUrl = settings.oauthAuthorizationUrl?.trim();
const tokenUrl = settings.oauthTokenUrl?.trim();
if (!authorizationUrl && !tokenUrl) return undefined;
return {
...(authorizationUrl && { authorizationUrl }),
...(tokenUrl && { tokenUrl }),
};
}
/**
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
* and the rest of the in-memory layer consume. Returns `undefined` when none
* of the source fields are present so callers can skip attaching a settings
* node to entries that don't have one.
*
* `headers` becomes a pair-array preserving the object's key insertion order;
* `oauth.*` becomes the flat `oauthClientId` / `oauthClientSecret` /
* `oauthScopes` fields. The numeric timeouts get concrete values when absent
* because the form needs them to render: `connectionTimeout` reads back as
* `DEFAULT_CONNECTION_TIMEOUT_MS` (30 s), while `requestTimeout` reads back as
* 0, the SDK's "use your own default" signal. An explicit `connectionTimeout`
* of 0 is preserved — it is the user's "no timeout" opt-out (#2320).
*
* `env` / `cwd` are SDK config fields (not Inspector-extension keys), but they
* are mirrored into the settings here so the Server Settings modal can edit
* them for stdio servers. They are NOT re-emitted by
* `inspectorSettingsToStoredFields` — the write side lives in the PUT route's
* write-through (config stays the single on-disk owner). Their presence alone
* is enough to materialize a settings node so a bare `{ command, env }` entry
* surfaces its env in the form.
*/
export function storedFieldsToInspectorSettings(
stored: StoredInspectorFields & {
env?: Record<string, string>;
cwd?: string;
},
): InspectorServerSettings | undefined {
const hasAny =
stored.headers !== undefined ||
stored.metadata !== undefined ||
stored.connectionTimeout !== undefined ||
stored.requestTimeout !== undefined ||
stored.taskTtl !== undefined ||
stored.autoRefreshOnListChanged !== undefined ||
stored.paginatedLists !== undefined ||
stored.advertisedExtensions !== undefined ||
stored.maxFetchRequests !== undefined ||
stored.skillCatalogMaxSkills !== undefined ||
stored.skillCatalogMaxBytes !== undefined ||
stored.oauth !== undefined ||
stored.roots !== undefined ||
stored.protocolEra !== undefined ||
stored.modernLogLevel !== undefined ||
stored.env !== undefined ||
stored.cwd !== undefined;
if (!hasAny) return undefined;
const headersPairs: { key: string; value: string }[] = stored.headers
? Object.entries(stored.headers).map(([key, value]) => ({ key, value }))
: [];
const settings: InspectorServerSettings = {
headers: headersPairs,
env: envRecordToPairs(stored.env),
metadata: normalizeStoredMetadata(stored.metadata),
// Concrete product default, like taskTtl below: an absent field reads back
// as 30 s so the form shows the bound that will actually apply. An explicit
// 0 is preserved as-is — it is the user's opt-out, not a sentinel (#2320).
connectionTimeout:
stored.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS,
requestTimeout: stored.requestTimeout ?? 0,
// Unlike requestTimeout (0 = "SDK default"), task TTL has a concrete product
// default so the form shows it and "Run as task" has a value to send.
taskTtl: stored.taskTtl ?? DEFAULT_TASK_TTL_MS,
autoRefreshOnListChanged: stored.autoRefreshOnListChanged ?? false,
paginatedLists: stored.paginatedLists ?? false,
// Concrete default like taskTtl (not a 0-sentinel): the form needs a value
// to render and the log state needs one to size its buffer. An absent
// on-disk field reads back as the default, which the write side then omits.
maxFetchRequests: stored.maxFetchRequests ?? DEFAULT_MAX_FETCH_REQUESTS,
// Defaults to an empty list so the form always has a concrete array to
// render controlled rows from. An absent on-disk `roots` reads back as
// `[]`, which `inspectorSettingsToStoredFields` then omits on write.
roots: stored.roots ?? [],
};
// Lifted only when usable (#2294). Unlike maxFetchRequests these stay
// optional in memory — absent means the default, which
// `resolveSkillCatalogBudget` supplies — and a hand-edited `0` or `-1` is
// dropped here rather than carried into a form that would re-persist it.
if (isSkillCatalogLimit(stored.skillCatalogMaxSkills)) {
settings.skillCatalogMaxSkills = stored.skillCatalogMaxSkills;
}
if (isSkillCatalogLimit(stored.skillCatalogMaxBytes)) {
settings.skillCatalogMaxBytes = stored.skillCatalogMaxBytes;
}
// Absent on disk reads back as the default era; the write side then omits the
// default so a byte-stable round-trip never injects `protocolEra` into files
// that never set it. An unknown literal from a hand-edited file is dropped
// (→ default legacy) rather than passed through to `eraToVersionNegotiation`.
if (isProtocolEra(stored.protocolEra)) {
settings.protocolEra = stored.protocolEra;
}
// Like `protocolEra`: absent reads back as the default modern log level (the
// form defaults via `?? DEFAULT_MODERN_LOG_LEVEL`), and an unknown literal from
// a hand-edited file is dropped rather than surfaced.
if (isModernLogLevel(stored.modernLogLevel)) {
settings.modernLogLevel = stored.modernLogLevel;
}
// Optional map with no default; carried through only when the file has a
// non-empty object. An absent/empty field reads back as unset, which the
// write side then omits — keeping a byte-stable round-trip.
if (
stored.advertisedExtensions &&
Object.keys(stored.advertisedExtensions).length > 0
) {
settings.advertisedExtensions = { ...stored.advertisedExtensions };
}
// Truthiness drops empty-string OAuth fields — mirrors the write-side
// coercion in `validateSettings` (server.ts) so a round-trip can't
// accidentally surface `oauthClientId: ""` to the form, where the
// OAuth manager would misread it as "configured."
if (stored.oauth?.clientId) settings.oauthClientId = stored.oauth.clientId;
if (stored.oauth?.clientSecret)
settings.oauthClientSecret = stored.oauth.clientSecret;
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
// Optional record with no default; carried through only when the file has a
// non-empty object, so an absent/empty field reads back as unset and the
// write side then omits it — keeping a byte-stable round-trip. Sanitized
// rather than trusted: only the web client's `/api/servers` route validates
// this field, while the CLI/TUI read `mcp.json` straight off disk. (#2018)
const authorizationParams = cleanAuthorizationParams(
stored.oauth?.authorizationParams,
);
if (authorizationParams) {
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
}
// Truthiness drops empty strings like the credential fields above, so a
// cleared field reads back as unset rather than as an empty override. The
// `string` type is a compile-time promise a hand-edited `mcp.json` does not
// keep, and only the web client's `/api/servers` route checks it — the CLI and
// TUI read the file directly and then call `.trim()` on these in
// `oauthEndpointOverridesFromSettings`, so a non-string would crash the server
// load. Sanitized like `authorizationParams` above rather than trusted.
// (#1906)
const authorizationUrl = cleanEndpointOverride(
stored.oauth?.authorizationUrl,
"authorizationUrl",
);
if (authorizationUrl) settings.oauthAuthorizationUrl = authorizationUrl;
const tokenUrl = cleanEndpointOverride(stored.oauth?.tokenUrl, "tokenUrl");
if (tokenUrl) settings.oauthTokenUrl = tokenUrl;
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
if (stored.oauth?.enterpriseManaged === true) {
settings.enterpriseManaged = true;
}
// Inverted from the flags above: the default is *on*, so only an explicit
// `false` is carried through. Anything else on disk (absent, or a
// hand-edited non-boolean) reads back as unset, i.e. the default. (#2068)
if (stored.oauth?.requestRefreshToken === false) {
settings.oauthRequestRefreshToken = false;
}
// Same inverted default as the flag above: revocation is on unless the entry
// explicitly opted out. (#2144)
if (stored.oauth?.revokeOnClear === false) {
settings.oauthRevokeOnClear = false;
}
// Mirror the stdio working directory for the form. Like the OAuth fields, an
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
if (stored.cwd) settings.cwd = stored.cwd;
return settings;
}
/**
* Splat the form-shape `InspectorServerSettings` back into the on-disk
* Inspector-extension fields (object-form `headers`, nested `oauth`, etc.).
* Empty-key rows are dropped — the form lets users leave new rows blank
* mid-edit and those shouldn't reach disk. Timeouts at their default are
* omitted so the file diff stays minimal for entries that never touched them:
* that is `DEFAULT_CONNECTION_TIMEOUT_MS` for `connectionTimeout` (an explicit
* 0 is a chosen opt-out and is written) and 0 for `requestTimeout`.
*
* Returns the field deltas to merge onto a `StoredMCPServer`; callers can
* spread the result.
*/
export function inspectorSettingsToStoredFields(
settings: InspectorServerSettings,
): StoredInspectorFields {
const out: StoredInspectorFields = {};
const headersRecord: Record<string, string> = {};
for (const { key, value } of settings.headers) {
if (key.trim() === "") continue;
headersRecord[key] = value;
}
if (Object.keys(headersRecord).length > 0) {
out.headers = headersRecord;
}
// Blank-key entries can't exist in the object form (an object has no blank
// key the user can leave mid-edit, the way a `{key,value}` row could), so
// there is nothing to filter — only the empty-object case to omit.
if (Object.keys(settings.metadata).length > 0) {
out.metadata = settings.metadata;
}
// The product default is the omit-sentinel (as for taskTtl below), so a file
// that never named a timeout stays byte-stable. Anything else persists —
// including 0, which is now a non-default the user chose (no timeout) and
// would otherwise silently read back as 30 s on the next load (#2320).
if (settings.connectionTimeout !== DEFAULT_CONNECTION_TIMEOUT_MS) {
out.connectionTimeout = settings.connectionTimeout;
}
if (settings.requestTimeout > 0) {
out.requestTimeout = settings.requestTimeout;
}
// Persist taskTtl only when it's a non-default positive value. The product
// default (DEFAULT_TASK_TTL_MS) is the omit-sentinel here — an absent field
// reads back as the default (above), so writing the default would inject it
// into hand-edited files that never had it and break byte-stable round-trips.
if (settings.taskTtl > 0 && settings.taskTtl !== DEFAULT_TASK_TTL_MS) {
out.taskTtl = settings.taskTtl;
}
// Persist only when enabled — absent reads back as false (above), keeping the
// diff minimal for the common (default-off) case.
if (settings.autoRefreshOnListChanged) {
out.autoRefreshOnListChanged = true;
}
// Persist only when enabled — absent reads back as false (above).
if (settings.paginatedLists) {
out.paginatedLists = true;
}
// Persist only when the user has toggled at least one extension override;
// an empty map reads back as unset (above), keeping the diff minimal for the
// common (no-override) case.
if (
settings.advertisedExtensions &&
Object.keys(settings.advertisedExtensions).length > 0
) {
out.advertisedExtensions = { ...settings.advertisedExtensions };
}
// Persist only when it differs from the default era. Absent reads back as
// DEFAULT_PROTOCOL_ERA, so writing the default would inject the field into
// hand-edited files that never had it and break byte-stable round-trips.
if (
settings.protocolEra !== undefined &&
settings.protocolEra !== DEFAULT_PROTOCOL_ERA
) {
out.protocolEra = settings.protocolEra;
}
// Persist only when it differs from the default modern log level; absent reads
// back as DEFAULT_MODERN_LOG_LEVEL, so writing the default would inject the
// field into files that never set it and break byte-stable round-trips.
if (
settings.modernLogLevel !== undefined &&
settings.modernLogLevel !== DEFAULT_MODERN_LOG_LEVEL
) {
out.modernLogLevel = settings.modernLogLevel;
}
// Persist only when it differs from the default. Like connectionTimeout and
// unlike requestTimeout, 0 is a meaningful value here (unlimited), so the
// omit-sentinel is the default itself rather than 0 — writing the default
// would inject the field into hand-edited files that never had it and break
// byte-stable round-trips.
if (settings.maxFetchRequests !== DEFAULT_MAX_FETCH_REQUESTS) {
out.maxFetchRequests = settings.maxFetchRequests;
}
// Same omit-the-default rule (#2294). An unusable value writes nothing, so
// it reads back as the default rather than being persisted and then
// rejected on the next load.
if (
isSkillCatalogLimit(settings.skillCatalogMaxSkills) &&
settings.skillCatalogMaxSkills !== SKILL_MAX_CATALOG_SKILLS
) {
out.skillCatalogMaxSkills = settings.skillCatalogMaxSkills;
}
if (
isSkillCatalogLimit(settings.skillCatalogMaxBytes) &&
settings.skillCatalogMaxBytes !== SKILL_MAX_CATALOG_BYTES
) {
out.skillCatalogMaxBytes = settings.skillCatalogMaxBytes;
}
const oauthFields: NonNullable<StoredMCPServer["oauth"]> = {};
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
if (settings.oauthClientSecret)
oauthFields.clientSecret = settings.oauthClientSecret;
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
// Blank-key rows (a row the user added but never filled in) are dropped, so
// an all-blank list writes nothing — matching the read side's omit-on-empty.
if (settings.oauthAuthorizationParams) {
const authParams = envPairsToRecord(settings.oauthAuthorizationParams);
if (Object.keys(authParams).length > 0) {
oauthFields.authorizationParams = authParams;
}
}
// Empty/blank reads back as unset (above), so a cleared field writes nothing
// and the file diff stays minimal for servers that never set one. (#1906)
if (settings.oauthAuthorizationUrl?.trim()) {
oauthFields.authorizationUrl = settings.oauthAuthorizationUrl.trim();
}
if (settings.oauthTokenUrl?.trim()) {
oauthFields.tokenUrl = settings.oauthTokenUrl.trim();
}
if (settings.oauthOnInsufficientScope) {
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
}
if (settings.enterpriseManaged === true) {
oauthFields.enterpriseManaged = true;
}
// Only the non-default (off) is written; an absent field reads back as the
// default (on), so writing `true` would inject the key into files that never
// set it and break byte-stable round-trips. (#2068)
if (settings.oauthRequestRefreshToken === false) {
oauthFields.requestRefreshToken = false;
}
// Same omit-the-default rule as the flag above. (#2144)
if (settings.oauthRevokeOnClear === false) {
oauthFields.revokeOnClear = false;
}
if (Object.keys(oauthFields).length > 0) {
out.oauth = oauthFields;
}
// Drop empty-uri rows / blank names via the shared normalizer; omit the
// field entirely when nothing survives, keeping the diff minimal for entries
// that never configured roots.
const rootsFiltered = cleanRoots(settings.roots);
if (rootsFiltered.length > 0) {
out.roots = rootsFiltered;
}
return out;
}
/**
* Source of truth for the set of Inspector-extension keys that live at the
* top level of a `StoredMCPServer`. Enumerated through a map keyed by
* `keyof StoredInspectorFields` with a `satisfies` constraint so any new
* field added to the type forces a compile error here — the disk → memory
* converter slice, the server-side smuggle guard, and the PUT preserve
* path all derive from this single source.
*
* Don't replace this with a hand-typed string array — `satisfies
* Record<keyof StoredInspectorFields, true>` is what gives us the
* exhaustive check. `as const` plus the `satisfies` clause yields a
* narrow tuple-of-literals type that downstream consumers can use as
* `(keyof StoredInspectorFields)[]`.
*/
const INSPECTOR_FIELD_KEY_MAP = {
headers: true,
metadata: true,
protocolEra: true,
modernLogLevel: true,
connectionTimeout: true,
requestTimeout: true,
taskTtl: true,
autoRefreshOnListChanged: true,
paginatedLists: true,
advertisedExtensions: true,
maxFetchRequests: true,
skillCatalogMaxSkills: true,
skillCatalogMaxBytes: true,
oauth: true,
roots: true,
} as const satisfies Record<keyof StoredInspectorFields, true>;
export const INSPECTOR_FIELD_KEYS = new Set(
Object.keys(INSPECTOR_FIELD_KEY_MAP) as (keyof StoredInspectorFields)[],
);
/**
* Strip the Inspector-extension fields off a `StoredMCPServer` so the
* remainder is the pure SDK config shape the PUT route's preserve path
* needs. Source-of-truth driven via `INSPECTOR_FIELD_KEYS` so adding a
* new extension field doesn't silently leak through this slice — the
* `satisfies` constraint above forces the map update, which propagates
* here.
*
* Every Inspector-extension key is optional on `StoredMCPServer`, so deleting
* them off a clone leaves a value still typed as (a subtype of)
* `MCPServerConfig` — no cast needed.
*/
export function stripInspectorFields(stored: StoredMCPServer): MCPServerConfig {
const out = { ...stored };
for (const key of INSPECTOR_FIELD_KEYS) {
delete out[key];
}
return out;
}
/**
* Convert the on-disk `MCPConfig` into the `ServerEntry[]` the Servers screen
* consumes. Map key becomes both `id` and `name`. Connection state initializes
* to `disconnected` — the React layer drives it from there. Inspector-extension
* fields (post-#1358 flat shape) are lifted into `ServerEntry.settings` so the
* rest of the app sees `config` as the pure SDK shape.
*/
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
return Object.entries(config.mcpServers).map(([id, raw]) => {
// Separate Inspector-extension fields from the SDK-only config so the
// transport never sees `entry.config.headers` (which would be ambiguous
// — pair-array in memory, object on disk). Headers live on the wire via
// `InspectorServerSettings` only.
const inspectorFields: StoredInspectorFields = {};
const sdkOnly: Record<string, unknown> = {};
// Partition the entry's keys into Inspector-extension vs SDK-only. Both
// `raw` and `inspectorFields` are widened through `toRecord` (see its doc)
// so the generic key iteration/assignment needs no per-site cast.
const inspectorRecord = toRecord(inspectorFields);
for (const [k, v] of Object.entries(toRecord(raw))) {
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) {
inspectorRecord[k] = v;
} else {
sdkOnly[k] = v;
}
}
const normalizedConfig = normalizeServerType(
sdkOnly as Record<string, unknown> & { type?: string },
);
const entry: ServerEntry = {
id,
name: id,
config: normalizedConfig,
connection: { status: "disconnected" },
};
// Mirror the stdio `env` / `cwd` (SDK config fields) into the settings so
// the Server Settings modal can edit them. They stay on `config` for the
// transport. Gate on the stdio type rather than blindly casting — a non-
// stdio config carries neither field, matching the modal's stdio-only UI.
const isStdio =
normalizedConfig.type === "stdio" || normalizedConfig.type === undefined;
const stdioConfig = isStdio
? (normalizedConfig as StdioServerConfig)
: undefined;
const settings = storedFieldsToInspectorSettings({
...inspectorFields,
env: stdioConfig?.env,
cwd: stdioConfig?.cwd,
});
if (settings !== undefined) entry.settings = settings;
return entry;
});
}
/**
* Convert `ServerEntry[]` back into `MCPConfig` for serialization. Strips
* runtime-only fields (connection, info, name); persists `config` plus the
* Inspector-extension fields as direct keys on the entry (post-#1358 flat
* shape) so the file matches the Claude Code / Cursor / Cline `.mcp.json`
* convention.
*/
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig {
const mcpServers: Record<string, StoredMCPServer> = {};
for (const entry of entries) {
const stored: StoredMCPServer = { ...entry.config } as StoredMCPServer;
if (entry.settings !== undefined) {
Object.assign(stored, inspectorSettingsToStoredFields(entry.settings));
}
mcpServers[entry.id] = stored;
}
return { mcpServers };
}
/**
* Canonical JSON serialization for `mcp.json` files. Two-space indent — the
* same format `serializeStore` in core/storage/store-io.ts writes on the
* backend, so a round-trip through export → hand-edit → import preserves
* the on-disk shape. Browser-safe (no Node imports); the backend uses the
* Node-only serializeStore but the formatting must match.
*/
export function serializeMcpConfig(entries: ServerEntry[]): string {
return JSON.stringify(serverEntriesToMcpConfig(entries), null, 2);
}
/**
* Result of splitting secret values off a `StoredMCPServer` for keychain
* persistence. `stripped` is what gets written to `mcp.json` on disk;
* `secrets` is the (field → value) map the keychain backend writes.
*
* Empty-string values are not written to keychain (they have the same
* semantic meaning as absence). Callers that want full reconcile
* semantics on update should also call `deleteAllForServer` first.
*/
export interface ExtractedSecrets {
stripped: StoredMCPServer;
secrets: Record<string, string>;
}
/**
* Type guard for the stdio branch of `MCPServerConfig`. The `type` field
* is optional on `StdioServerConfig` because stdio is the implicit
* default for entries written without a `type` key (matches Claude
* Desktop). So both `undefined` and the literal "stdio" route here.
*/
const isStdioStored = (
stored: StoredMCPServer,
): stored is StdioServerConfig & StoredMCPServer =>
stored.type === "stdio" || stored.type === undefined;
/**
* Strip secret values from a single on-disk entry. Returns the
* sanitized disk shape (oauth.clientSecret removed; stdio env values
* cleared to "") plus the map of field → value the keychain should
* hold.
*
* stdio env keys are preserved with empty-string values rather than
* dropped, so the on-disk file still documents the env interface the
* server expects (a user reading mcp.json can see "this server uses
* API_KEY and DB_PASSWORD" even though the values live in the
* keychain). Round-tripping with another tool that reads mcp.json
* gets the same key set but empty values, which is the intended
* trade-off: the secret never reaches another tool, but the key list
* is still visible.
*/
export function extractSecretsFromStored(
stored: StoredMCPServer,
): ExtractedSecrets {
const secrets: Record<string, string> = {};
const stripped: StoredMCPServer = { ...stored };
if (stored.oauth?.clientSecret) {
secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET] = stored.oauth.clientSecret;
// Keep every OAuth field except the one secret, rather than enumerating the
// non-secret ones. An allow-list here is a standing trap: it silently drops
// any field added to `oauth` afterwards, and it had already done so —
// `onInsufficientScope` was lost whenever a server carried both a client
// secret and a non-default SEP-2350 policy. Subtracting the secret instead
// means a future field is preserved by construction. (#2018)
const { clientSecret: _clientSecret, ...restOauth } = stored.oauth;
if (Object.keys(restOauth).length > 0) {
stripped.oauth = restOauth;
} else {
delete stripped.oauth;
}
}
if (isStdioStored(stripped)) {
const env = stripped.env;
if (env) {
const newEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (typeof v === "string" && v.length > 0) {
secrets[envSecretField(k)] = v;
}
newEnv[k] = "";
}
stripped.env = newEnv;
}
}
return { stripped, secrets };
}
/**
* Inverse of `extractSecretsFromStored`. Merges a pre-fetched secrets
* record back into an on-disk entry. Used at the `/api/servers` GET
* boundary so the browser receives the same effective shape it has
* today.
*
* A missing key in `secrets` leaves the corresponding field alone,
* which matters for stdio env: if the keychain doesn't have a value
* for `env:KEY`, the on-disk empty string passes through unchanged.
*/
export function mergeSecretsIntoStored(
stored: StoredMCPServer,
secrets: Record<string, string>,
): StoredMCPServer {
const out: StoredMCPServer = { ...stored };
const oauthSecret = secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET];
if (oauthSecret) {
out.oauth = { ...(out.oauth ?? {}), clientSecret: oauthSecret };
}
if (isStdioStored(out) && out.env) {
const newEnv: Record<string, string> = { ...out.env };
let mutated = false;
for (const k of Object.keys(out.env)) {
const val = secrets[envSecretField(k)];
if (val !== undefined) {
newEnv[k] = val;
mutated = true;
}
}
if (mutated) {
out.env = newEnv;
}
}
return out;
}
/**
* Enumerate the keychain field identifiers an on-disk entry expects
* to find values for. Handlers use this to know which keychain entries
* to fetch when rehydrating a server, and which keys to reconcile on
* update (env keys removed by the user should drop their keychain
* entries).
*
* Order is stable: OAuth first, then env keys in object iteration
* order. Callers that diff old vs new field sets rely on stable
* enumeration to avoid spurious churn.
*/
export function expectedSecretFields(stored: StoredMCPServer): string[] {
const fields: string[] = [];
// Always include OAuth slot — even if the entry has no `oauth` block
// on disk, the keychain may hold a leftover entry from a prior
// configuration that we want callers to be able to reconcile.
fields.push(SECRET_FIELD_OAUTH_CLIENT_SECRET);
if (isStdioStored(stored) && stored.env) {
for (const k of Object.keys(stored.env)) {
fields.push(envSecretField(k));
}
}
return fields;
}
/**
* Public MCP org demo server seeded as the remote example (#2201). It is the
* feature-reference server the MCP org hosts, so pointing a shipped default at
* it keeps the seed in-org rather than at a third-party endpoint.
*
* Its authorization server advertises Dynamic Client Registration and fronts a
* *mock* upstream IdP, so the whole OAuth round trip completes with no account,
* no API key and no pre-registered client — which is what makes it usable as a
* seed at all. Everything it exposes is synthetic reference data.
*/
export const EXAMPLE_SERVER_URL =
"https://example-server.modelcontextprotocol.io/mcp";
/**
* Default seeds written to `~/.mcp-inspector/mcp.json` on first launch when
* the file is absent. Picked to cover the three shapes a developer reaches for
* first: a real filesystem scoped to /tmp, the canonical "everything"
* reference server, and a remote Streamable HTTP server behind OAuth.
*
* The remote seed carries no `protocolEra`, so it connects under
* `DEFAULT_PROTOCOL_ERA` (`"legacy"`) like every other entry that omits the
* field. That matches the server, which answers the plain `initialize`
* handshake at 2025-11-25 and rejects `server/discover` — it has no modern
* (SEP-2663) era to negotiate, so `"auto"` would only cost a failed probe and
* `"modern"` would fail the connection outright.
*/
export const DEFAULT_SEED_CONFIG: MCPConfig = {
mcpServers: {
"filesystem-server-default": {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
"everything-server-default": {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-everything"],
},
"example-server-default": {
type: "streamable-http",
url: EXAMPLE_SERVER_URL,
},
},
};