inspector/core/mcp/serverList.ts
Cliff Hall 420da2e415
feat(servers): export server list as mcp.json download (#1346) (#1351)
* feat(servers): export server list as mcp.json download (#1346)

Adds an Export button to the Servers screen, next to "Add Servers".
Clicking it downloads the current server list as a canonical mcp.json
file (2-space indent, same shape serializeStore writes), so the user
can hand it off, sync it to another machine, or symlink into Claude
Desktop / Cursor / Cline.

Wiring:
- ServerListControls: new Export Button (variant=default), disabled
  when serverCount === 0 (nothing to download).
- ServerListScreen / InspectorView: thread `onExport` / `onServerExport`
  through.
- App.tsx: onServerExport runs serverEntriesToMcpConfig on the in-memory
  servers list, JSON.stringify with 2-space indent, and triggers a Blob
  download via a temporary anchor (appended to body for Firefox
  compatibility, then removed; object URL revoked).

Tests:
- ServerListControls unit tests cover button-count (toggle hidden vs
  shown, Export always present), disabled-on-empty, click fires onExport.
- ServerListControls.stories grows play functions for both stories —
  WithServers asserts Export is enabled + click fires the spy in real
  Chromium, WithoutServers asserts Export is disabled.
- Existing screen + view tests / stories updated for the new prop.

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

* fix(servers): address PR #1351 review (extract helpers + exception safety + guard)

Substantive items from the review:

1. Serialization format parity (review #1): added
   `serializeMcpConfig(entries)` to core/mcp/serverList.ts — a
   browser-safe helper that runs `serverEntriesToMcpConfig` then
   stringifies with 2-space indent. Single source of truth for export
   formatting; can't drift from serializeStore's on-disk shape.

2. Exception safety on the anchor cleanup (review #2) + a direct test
   for the App.tsx download wiring (review #6): extracted
   `downloadJsonFile(filename, json)` to clients/web/src/lib/
   downloadFile.ts. removeChild + revokeObjectURL run in a `finally`
   so a thrown click() doesn't leak the DOM node or the object URL.
   Unit-tested under happy-dom with URL.createObjectURL mocked
   (happy-dom doesn't ship it).

3. Empty-list guard (review #5): App.tsx onServerExport bails when
   `servers.length === 0`. The button's disabled prop already prevents
   the UI path, but the handler is now locally correct against any
   future programmatic caller (storybook, keyboard shortcut, etc.).

Acknowledged but not changed:
- Tooltip on the disabled Export button (review #3) — Mantine's
  disabled-button + Tooltip wart wants a span wrapper + custom events
  config. Optional per the reviewer; deferred.
- Repeated exports → `mcp (1).json` browser suffix (review #4) — a
  deliberate trade-off since `mcp.json` is the conventional name a user
  would expect to symlink into Claude Desktop / Cursor / etc.

Tests: 1647 unit (+6) + 410 integration + 306 storybook = 2363 green.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:15:56 -04:00

111 lines
3.8 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 type {
MCPConfig,
MCPServerConfig,
ServerEntry,
ServerType,
} from "./types.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;
}
/**
* 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.
*/
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
return Object.entries(config.mcpServers).map(([id, raw]) => ({
id,
name: id,
config: normalizeServerType(
raw as unknown as Record<string, unknown> & { type?: string },
),
connection: { status: "disconnected" },
}));
}
/**
* Convert `ServerEntry[]` back into `MCPConfig` for serialization. Strips
* runtime-only fields (connection, info, name) — only id and config make it
* to disk so the file stays a clean canonical `mcp.json`.
*/
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig {
const mcpServers: Record<string, MCPServerConfig> = {};
for (const entry of entries) {
mcpServers[entry.id] = entry.config;
}
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);
}
/**
* 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"],
},
},
};