inspector/core/mcp/serverList.ts
Cliff Hall b0a024269e
feat(web): list-changed handling — indicator, pull-on-demand default, and per-server auto-refresh opt-in (#1402) (#1443)
* feat(web): wire the listChanged indicator across all screens (#1402)

The per-screen list-changed indicator was hardcoded false in
InspectorView for Tools, Apps, Prompts, and Resources, so it could
never light up. Source it from the managed-state layer instead.

Each ManagedXState now tracks a listChanged flag: set true when its
*/list_changed notification arrives (the existing auto-refresh leaves
it set), exposed via getListChanged()/a listChangedChange event, and
cleared by clearListChanged() or on disconnect. The useManagedX hooks
surface the flag and clear it on a user-initiated refresh (the
indicator's Refresh button = acknowledge). App threads the three flags
to InspectorView, which maps them to the screens — Apps shares the
tools flag since apps are a filtered view of tools.

Tests cover set/clear/disconnect at the state-manager layer, the hook
subscription + refresh-clears behavior, and the InspectorView routing
(including Apps sharing the tools flag and no cross-wiring between
screens).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): close clear-after-await race in managed-list refresh (#1402 review)

The useManagedX refresh() callbacks cleared the listChanged flag AFTER
awaiting the fetch. If a */list_changed arrived mid-fetch, the state
re-set the flag (and auto-refreshed), then the trailing clear wiped that
genuinely-new signal. Clear BEFORE the await instead: the user's refresh
acknowledges only the change in hand, and a mid-fetch change re-sets the
flag and survives. Adds a race regression test on the tools hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): drop duplicate Refresh on the Apps screen (#1402)

AppControls uniquely had a standalone toolbar Refresh button AND the
ListChangedIndicator on a separate row, so once the indicator could
actually appear (this PR), the Apps screen showed two Refresh buttons.
Match the other three controls (Tools/Prompts/Resources): the indicator
is the single header-right refresh affordance. Updates AppControls and
AppsScreen tests to assert exactly one Refresh (and none when the list
hasn't changed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): stop auto-invoking */list on list_changed; user pulls via Refresh (#1402)

With auto-refresh on, the list was already current by the time the
list-changed indicator appeared, making its Refresh button moot. Now the
managed Tools/Prompts/Resources state managers only flag the change on
*/list_changed (they no longer auto-refresh); the list stays as-is until
the user clicks Refresh, which fetches the new list and clears the
indicator. The connect-time initial load is unchanged.

Resource templates (no own indicator, part of the Resources screen) also
stop auto-refreshing; onRefreshResources now refreshes both resources and
templates, since a single resources/list_changed covers both. Tasks are
left auto-refreshing — they have no list-changed indicator and need live
status updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(core): correct ManagedResourceTemplatesState header comments (#1402 review)

The class no longer subscribes to resourceTemplatesListChanged; update
the stale file/class docstrings that still claimed it syncs on that
notification to reflect connect + statusChange only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): add per-server 'Auto Refresh on List Changed Notifications' option (#1402)

New Options section in ServerSettingsForm with a single checkbox. When
checked, the managed Tools/Prompts/Resources (and resource templates)
state auto-refreshes on */list_changed notifications; when unchecked
(default) the notification only lights the list-changed indicator and the
user pulls via Refresh.

Plumbing: new optional autoRefreshOnListChanged on InspectorServerSettings
and StoredMCPServer, round-tripped through serverList (lifted with a false
default, persisted only when true, added to the exhaustive field-key map);
surfaced from InspectorClient via a new getServerSettings() on the protocol;
the state managers read it live at notification time to decide auto-refresh
vs flag-only. Resource templates re-subscribe to their list_changed but only
auto-refresh when the option is on (they have no indicator of their own).

Tests cover the round-trip (true persists, false omitted), the form
section (render/checked/toggle), and each state manager's on/off branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): persist autoRefreshOnListChanged through validateSettings (#1402)

The PUT /api/servers write path runs settings through validateSettings,
which rebuilds the value from explicitly-named fields and silently drops
unknown keys — so autoRefreshOnListChanged never reached disk (the
serverList round-trip test passed because it calls
inspectorSettingsToStoredFields directly, bypassing this path). Validate
it as an optional boolean and include it in the built value; false is
omitted on disk by inspectorSettingsToStoredFields as before.

Adds integration tests over the real PUT path: true persists, false is
omitted, and a non-boolean is rejected (400).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): default the Server Settings modal to the Options section (#1402)

Open 'Options' instead of 'Custom Headers' when the Server Settings modal
first opens, so the new Auto Refresh toggle is the first thing surfaced.
Updates the modal default-expanded section, the modal test's initial-state
assertions, the three header tests (now expand 'Custom Headers' first since
it's no longer open by default), and the story defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop accidentally-committed Inspector history dump; gitignore the class

83076441 swept an exported Inspector message-history JSON (manual-testing
artifact at the repo root) into the tree via git add -A. Untrack it and
extend the existing repo-root debug-artifact .gitignore block (alongside
/*.png) to cover /inspector-history-*.json so these can't be re-added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:58:21 -04:00

507 lines
19 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_TASK_TTL_MS } from "./types.js";
import type { Root } from "@modelcontextprotocol/sdk/types.js";
import type {
InspectorServerSettings,
MCPConfig,
MCPServerConfig,
ServerEntry,
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",
]);
/**
* 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"
| "connectionTimeout"
| "requestTimeout"
| "taskTtl"
| "autoRefreshOnListChanged"
| "oauth"
| "roots"
>;
/**
* 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.
*/
export function storedFieldsToInspectorSettings(
stored: StoredInspectorFields,
): InspectorServerSettings | undefined {
const hasAny =
stored.headers !== undefined ||
stored.metadata !== undefined ||
stored.connectionTimeout !== undefined ||
stored.requestTimeout !== undefined ||
stored.taskTtl !== undefined ||
stored.autoRefreshOnListChanged !== undefined ||
stored.oauth !== undefined ||
stored.roots !== 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,
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,
// 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 ?? [],
};
// 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;
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;
}
const oauthFields: {
clientId?: string;
clientSecret?: string;
scopes?: string;
} = {};
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
if (settings.oauthClientSecret)
oauthFields.clientSecret = settings.oauthClientSecret;
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
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,
connectionTimeout: true,
requestTimeout: true,
taskTtl: true,
autoRefreshOnListChanged: 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> = {};
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" },
};
const settings = storedFieldsToInspectorSettings(inspectorFields);
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 } = {};
if (stored.oauth.clientId !== undefined)
restOauth.clientId = stored.oauth.clientId;
if (stored.oauth.scopes !== undefined)
restOauth.scopes = stored.oauth.scopes;
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"],
},
},
};