Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* Logging: per-request log level on modern servers (era fork) (#1629) On modern (2026-07-28) servers `logging/setLevel` and the session-scoped level are gone: the client opts into logs per request via the `io.modelcontextprotocol/logLevel` `_meta` key, and the SDK does not attach it automatically, so server logs are silently absent until stamped. Core (`InspectorClient`): - `setModernLogLevel(level | undefined)` / `getModernLogLevel()` store the opt-in level; `mergeMeta` stamps it on every outgoing request, gated on the negotiated modern era so legacy connections are untouched. - Cleared on disconnect so it can't leak into the next connection. Web UI: - `LogControls` forks on `protocolEra`: legacy keeps the `logging/setLevel` selector + Set button; modern shows a per-request opt-in Select (Off + levels, applied immediately) with copy explaining logs arrive on the originating request's stream. - Threaded `protocolEra` / `modernLogLevel` / `onSetModernLogLevel` through LoggingScreen and InspectorView from App. Tests: unit coverage for the modern LogControls/LoggingScreen fork; integration coverage proving the `logLevel` `_meta` is stamped on a real modern server once opted in (and not on legacy). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test-servers: logging era-fork showcase configs + proof screenshots (#1629) Add two manual-testing configs and README docs for the logging era fork: - logging-legacy-http.json: plain streamable-HTTP server with `logging: true` and a `send_notification` tool. The Logs tab shows the legacy session-scoped "Set Active Level" selector + Set button; calling the tool streams logs. - logging-modern-http.json: same, with `transport.modern: true`. Connect with Protocol Era = Modern and the Logs tab shows the per-request opt-in control; the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every request (visible in the Network tab request body). pr-screenshots/ documents the verified behavior end-to-end against both real servers: the legacy control with logs streaming, the modern per-request control, and the modern tools/call request body carrying the stamped logLevel _meta. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test-servers: send_notification emits via per-request notifier so modern logs stream (#1629) The `send_notification` tool sent its `notifications/message` through the global `server.server.notification()`. On the modern (2026-07-28) stateless leg that isn't tied to the in-flight request, so the SDK answered the tool call with plain JSON and dropped the log — nothing reached the Inspector's Logs panel. Prefer the per-request `extra.sendNotification` (rebuilt from `ctx.mcpReq.notify`): scoped to the current request, it makes the SDK upgrade the response to SSE and stream the log back on the originating request's stream. Falls back to the global notifier (legacy path / no per-request context). Verified end-to-end: opting in via the Logs-tab "Log Level per Request" control and calling send_notification now streams the entry on the modern server; legacy behavior unchanged (server-notification integration tests green). README documents the modern logging showcase and the caveat that this test server streams whenever a tool emits — the SDK beta strips the per-request envelope from the handler context, so the tool can't gate on the client's logLevel opt-in (the client-side `_meta` stamp #1629 owns is still fully visible in the Network tab). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test-servers: send_notification uses SDK request-scoped log() → spec-faithful modern gating (#1629) The previous fix routed `send_notification` through `extra.sendNotification` (`ctx.mcpReq.notify`), which streams on the modern leg but emits unconditionally — the log showed up even when the client hadn't opted in via the per-request `logLevel`. The SDK already provides the correct primitive: `ctx.mcpReq.log(level, data, logger)` (added in `McpServer.buildContext`). It applies the era-correct threshold gating itself — on the modern (2026-07-28) leg it reads the per-request `logLevel` opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from `logging/setLevel` — and emits through `notify`, so the modern response still upgrades to SSE and the log rides the originating request's stream. Surface it as `extra.log` (`toHandlerExtra` maps `ctx.mcpReq.log`) and have `send_notification` prefer it, falling back to the global notifier for callers without per-request context. Verified end to end: opting in via the Logs-tab "Log Level per Request" control stamps `logLevel` in the request `_meta` and streams the log; setting it back to Off omits the key and the server gates the log (plain JSON response, no notification). Legacy server-notification integration tests still green. (No SDK change needed — the earlier apparent gap was our test server using the wrong low-level API.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * web+core: add "Log Level per Request" as a per-server setting, defaulting to on (#1629) Adds the modern per-request log level as a persisted Server Settings option (Options → "Log Level per Request", below Protocol Era), so a server can opt into logs by default instead of the Logs-tab control starting at Off. - types.ts: `ModernLogLevel` (`"off"` | the 8 levels), `DEFAULT_MODERN_LOG_LEVEL` = "debug" (default-on so a modern connection surfaces server logs out of the box), `MODERN_LOG_LEVELS`, and an `isModernLogLevel` guard. Field added to `InspectorServerSettings` and `StoredMCPServer`. - serverList.ts: round-trip the field (lift on read, omit-on-default on write, drop unknown literals) mirroring `protocolEra`; add to the persisted field allowlist. - remote/node/server.ts: validateSettings accepts/validates it and the normalize path drops a malformed literal. - inspectorClient.ts: the constructor seeds `modernLogLevel` from the setting (`"off"` → not opted in), so a modern connection stamps the level from the first request without UI interaction. mergeMeta still gates on the modern era. - ServerSettingsForm/Modal: a Select (Off + 8 levels) with a modern-era description. App seeds the live Logs-tab control from the setting on connect so the two stay in sync. Tests: form Select (default/onChange/off/reflect), modal handler, serverList round-trips, route validation (accept/reject/drop), and constructor seeding (seeded level stamped from the first request; "off" opts out). The prior modern-era "stamps once set" test is updated for the new default-on behavior. Coverage gate green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Fix CI + review: format ServerSettingsForm, drop stray network export, broaden ignore - Prettier-format ServerSettingsForm.tsx (the format:check failure that broke CI — my pre-push `npm run format` from the repo root was a silent no-op since there is no root format script; formatting is per-client). - Remove `inspector-network-logging-modern-2026-07-19T15-11-27.013Z.json`, a Network-tab export debugging artifact accidentally committed in ca2aa526 (flagged by review). - Broaden the .gitignore debugging-artifacts section with `/inspector-network-*.json` so future network exports are ignored (the existing `/inspector-history-*.json` didn't match the network export naming). Verified with the full `npm run ci` (validate → coverage → smoke → storybook), green across all clients. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test: add modern-era log delivery + gating e2e tests (#1629) The modern-era suite covered the client-side `_meta` logLevel stamp but not that a log actually flows back — the legacy suite already has that (`should receive server-initiated notifications via SSE/streamable-http`), so the modern side was the gap. Add two live e2e tests against a modern (`createMcpHandler`) server serving the `send_notification` tool with `logging: true`: - opted-in (server setting = a level): calling the tool delivers a `notifications/message` over the request's SSE stream, with the right level/logger/data. - not opted in (setting "off"): the tool still returns a result, but the server gates the log — no `notifications/message` is received. This exercises the full path end to end: the client seeding + `_meta` opt-in, and the server's `send_notification` → `ctx.mcpReq.log` era-correct gating. Verified with the full `npm run ci` (green across all clients). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * web: hide the modern "Log Level per Request" setting on legacy connections (#1629) The per-request log level is meaningless on a legacy server (which uses logging/setLevel), so hide it in Server Settings when the era is legacy: - protocolEra pinned to "legacy" → hidden (no connection needed), or - protocolEra "auto" that negotiated legacy at connect time → hidden. It stays visible for a pinned "modern" server, and for an "auto" server that is either not yet connected (era unknown) or resolved to modern. Threads the negotiated era down: App passes `negotiatedEra` to ServerSettingsModal only when the settings target is the live-connected server (`connectionStatus === "connected" && settingsModalTargetId === activeServerId`); the modal forwards it to ServerSettingsForm, which gates the control on `configuredEra === "modern" || (configuredEra === "auto" && negotiatedEra !== "legacy")`. Tests: visibility matrix on the form (legacy hidden; auto+legacy hidden; auto+unconnected and auto+modern and modern shown) and a modal hide case; the existing modern-log tests now base on a modern-era settings object. Verified in the browser (legacy hides, switching to modern reveals it reactively) and with the full `npm run ci` (green across all clients). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
674 lines
26 KiB
TypeScript
674 lines
26 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_MAX_FETCH_REQUESTS,
|
|
DEFAULT_MODERN_LOG_LEVEL,
|
|
DEFAULT_PROTOCOL_ERA,
|
|
DEFAULT_TASK_TTL_MS,
|
|
isModernLogLevel,
|
|
} from "./types.js";
|
|
import type { Root } from "@modelcontextprotocol/client";
|
|
import type {
|
|
InspectorServerSettings,
|
|
MCPConfig,
|
|
MCPServerConfig,
|
|
ServerEntry,
|
|
ServerProtocolEra,
|
|
ServerType,
|
|
StdioServerConfig,
|
|
StoredMCPServer,
|
|
} from "./types.js";
|
|
import {
|
|
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
|
envSecretField,
|
|
} from "../auth/secret-fields.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;
|
|
}
|
|
|
|
/**
|
|
* Normalize the form's controlled root rows into the shape the Inspector
|
|
* advertises and persists: drop rows whose `uri` is blank (the 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. Shared by the settings → disk converter
|
|
* (`inspectorSettingsToStoredFields`) and the web client's connect-time +
|
|
* `setRoots` wiring so the roots told to the server match what hits disk.
|
|
*/
|
|
export function cleanRoots(roots: Root[]): Root[] {
|
|
return roots
|
|
.filter((r) => r.uri.trim() !== "")
|
|
.map((r) => {
|
|
const trimmedName = r.name?.trim();
|
|
// 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"
|
|
| "maxFetchRequests"
|
|
| "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`.
|
|
*/
|
|
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`.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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. Numeric timeouts default to 0 when absent — the form
|
|
* needs concrete values to render and 0 is the SDK's "no timeout" signal.
|
|
*
|
|
* `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.maxFetchRequests !== 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: stored.metadata ?? [],
|
|
connectionTimeout: stored.connectionTimeout ?? 0,
|
|
requestTimeout: stored.requestTimeout ?? 0,
|
|
// Unlike the timeouts (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 ?? [],
|
|
};
|
|
// 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;
|
|
}
|
|
// 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;
|
|
if (stored.oauth?.onInsufficientScope) {
|
|
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
|
|
}
|
|
if (stored.oauth?.enterpriseManaged === true) {
|
|
settings.enterpriseManaged = true;
|
|
}
|
|
// 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. Numeric timeouts at 0 are omitted
|
|
* so the file diff stays minimal for entries that never touched them.
|
|
*
|
|
* 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;
|
|
}
|
|
|
|
const metadataFiltered = settings.metadata.filter((m) => m.key.trim() !== "");
|
|
if (metadataFiltered.length > 0) {
|
|
out.metadata = metadataFiltered;
|
|
}
|
|
|
|
if (settings.connectionTimeout > 0) {
|
|
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 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. Unlike the timeouts, 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;
|
|
}
|
|
|
|
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;
|
|
if (settings.oauthOnInsufficientScope) {
|
|
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
|
|
}
|
|
if (settings.enterpriseManaged === true) {
|
|
oauthFields.enterpriseManaged = true;
|
|
}
|
|
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,
|
|
maxFetchRequests: 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.
|
|
*/
|
|
export function stripInspectorFields(stored: StoredMCPServer): MCPServerConfig {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(
|
|
stored as unknown as Record<string, unknown>,
|
|
)) {
|
|
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) continue;
|
|
out[k] = v;
|
|
}
|
|
return out as unknown as MCPServerConfig;
|
|
}
|
|
|
|
/**
|
|
* 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> = {};
|
|
// Widen the typed config object to a generic record to iterate its keys.
|
|
// `StoredMCPServer` has no index signature, so TS requires the `unknown`
|
|
// step (`as Record<string, unknown>` alone is TS2352). This is a plain
|
|
// structural widening, not an SDK-shape workaround. (Pre-existing pattern,
|
|
// also at the `serverEntryToStored` / oauth-strip casts in this file.)
|
|
for (const [k, v] of Object.entries(
|
|
raw as unknown as Record<string, unknown>,
|
|
)) {
|
|
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) {
|
|
(inspectorFields as Record<string, unknown>)[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;
|
|
const restOauth: {
|
|
clientId?: string;
|
|
scopes?: string;
|
|
enterpriseManaged?: boolean;
|
|
} = {};
|
|
if (stored.oauth.clientId !== undefined)
|
|
restOauth.clientId = stored.oauth.clientId;
|
|
if (stored.oauth.scopes !== undefined)
|
|
restOauth.scopes = stored.oauth.scopes;
|
|
if (stored.oauth.enterpriseManaged === true) {
|
|
restOauth.enterpriseManaged = true;
|
|
}
|
|
if (Object.keys(restOauth).length > 0) {
|
|
stripped.oauth = restOauth;
|
|
} else {
|
|
delete (stripped as unknown as Record<string, unknown>).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;
|
|
}
|
|
|
|
/**
|
|
* Default seeds written to `~/.mcp-inspector/mcp.json` on first launch when
|
|
* the file is absent. Picked to cover the two shapes a developer reaches for
|
|
* first: a real filesystem scoped to /tmp, and the canonical "everything"
|
|
* reference server.
|
|
*/
|
|
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"],
|
|
},
|
|
},
|
|
};
|