inspector/core/mcp/import/clientConfig.ts
Cliff Hall 97b1a7fbff
feat(web,core): import client configs and registry server.json (#1348) (#1511)
* feat(web,core): import client configs and registry server.json (#1348)

Wire up the two server-import affordances that were previously todoNoop:

- Import config: source picker (Claude Desktop / Cursor / Cline / VS Code)
  read on the backend via a new GET /api/import-source, plus a file-upload
  fallback. Per-server review with import/skip on new servers and
  overwrite/skip/rename on id conflicts, then a per-server outcome summary.
- Import server.json: parse the MCP registry single-server format
  (npm/pypi/oci packages + remotes, env vars, package/runtime args, URL
  template vars) into a runnable config, with a file picker and debounced
  live validation; Add Server is disabled until validation passes.

Core gains an isomorphic import strategy layer (core/mcp/import) with a
strategy registry, client-config + server.json parsers, a strategy-agnostic
merge, and a dependency-injected source resolver. useServers exposes
importSource(). Modal headers are bold and the redundant server.json section
header is folded into the modal title.

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

* feat(web): collapse the server.json textarea into a File Contents disclosure

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

* feat(web): auto-collapse File Contents disclosure after content loads

Once content is pasted or a file is loaded, collapse the File Contents
disclosure after ~1s with Mantine's built-in accordion animation, signaling
the input was accepted and surfacing the validation/env-var sections. The
disclosure becomes controlled by the modal; clearing the textarea re-opens it.

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

* feat(web): flash File Contents hover highlight before auto-collapse

Before the File Contents disclosure auto-collapses, paint it with its hover
background for 250ms, then run the collapse over a 325ms animation, so the
collapse reads as intentional.

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

* feat(web): rename "Import server.json" to "Import from registry config"

Rename the Add Servers menu entry and the modal header.

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

* feat(web): rename "Import config" to "Import client config"

Rename the Add Servers menu entry and the modal header.

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

* refactor(web,core): move import orchestration into core helpers + web hooks

Make the import logic reusable by CLI/TUI and keep the modals display-only:

- core/mcp/import: add parseClientConfig (mcpServers→servers fallback),
  resolveServerId, selectServerJsonOption, and buildServerConfigForSelection,
  so the full import (parse → select → build → merge) is callable from any
  environment.
- core/storage: extract the pure validateStoreId into a Node-free store-id
  module (store-io re-exports it) so the isomorphic import layer can validate
  ids without pulling Node deps into the browser bundle.
- clients/web/hooks: useServerJsonImport and useImportClientConfig own all the
  wizard/parse/debounce/apply state; ServerImportConfigModal and
  ServerImportJsonModal are now thin display shells over the dumb panel.

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

* feat(web): rename "Import client config" to "Import from client config"

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

* feat(web): remove the Cancel button from the registry import modal

The modal header X (and Escape) still dismiss it; drop the redundant Cancel
button (and the panel onCancel prop).

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

* feat(web): hide validation results + name override until content is present

In the registry import modal, the Validation Results section and the Server
Name override field stay hidden until something has been pasted/loaded into the
textarea.

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

* feat(web): make Validation Results a collapsible disclosure

Wrap the registry import modal's validation results in a Mantine accordion
disclosure (default open), matching the File Contents section.

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

* feat(web): Server Name disclosure with read-only derived name + override

Make Server Name a collapsible disclosure containing a read-only "From
configuration" field (the id derived from the server.json name) and the
editable Override field.

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

* feat(web): scroll to and highlight a freshly-added server

After a manual add/clone or an import (client config / registry), the new
server's card scrolls into view and draws an animated border (Mantine Border
Animate); clicking anywhere on the card dismisses it.

- App tracks highlightedServerId; addServerHighlighted sets it (edits and
  conflict-overwrites don't). Threaded through InspectorView -> ServerListScreen
  -> ServerCard.
- ServerCard scrolls into view on highlight and wraps in <BorderAnimate>;
  onClick clears the highlight.
- Pin @gfazioli/mantine-border-animate@2.0.3 (Mantine 8 compatible; 2.0.4+
  requires Mantine 9).

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

* fix(web): highlighted card fills its cell and its controls work on first click

- The BorderAnimate wrapper is display:flex, so the card was shrinking to
  content width while the border spanned the cell. Stretch the card with
  w=100% (and the wrapper).
- Always render BorderAnimate and toggle show/animate instead of conditionally
  wrapping: swapping the element type on clear remounted the card, swallowing
  the click that triggered it (e.g. the connect toggle fired clear but not
  connect, needing a second click). Verified: one click now connects + clears.

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

* fix(web,core): address review — OCI env, untrack fixture, rename validation

- Untrack the stray inspector-v1-multi.server.json (accidentally committed) and
  gitignore root *.server.json fixtures.
- core/mcp/import/serverJson: forward declared env vars into OCI containers via
  '-e KEY' docker args (they previously only set the docker CLI process env);
  add a test. Clarify buildServerConfig (destructure env out instead of
  spread-then-delete) and document value-vs-default env handling.
- useImportClientConfig: validate 'rename' targets inline (invalid charset /
  collision with existing, additions, or other renames) and gate the Import
  button on canImport; surface the error on the field.

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

* feat(web): highlight all servers from a batch import, not just the last

Track highlighted servers as a set of ids instead of one. addServerHighlighted
accumulates each id (a multi-server client-config import highlights them all);
opening an add/import modal starts a fresh batch. Clicking a card clears only
that card's highlight; only the first highlighted card scrolls into view.

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-23 19:52:08 -04:00

105 lines
4 KiB
TypeScript

/**
* Parsers that turn another MCP client's config file into the canonical
* Inspector `MCPConfig`. Pure + isomorphic (no Node deps).
*
* Two shapes are handled:
* - the common `{ mcpServers: { ... } }` shape used by Claude Desktop, Cursor,
* Cline, Claude Code (`parseMcpServersConfig`);
* - VS Code's native MCP shape with a top-level `servers` map (and an optional
* `inputs` array we ignore) (`parseVsCodeConfig`).
*
* Both delegate per-server normalization to `normalizeServerType` so a missing
* `type` defaults to `stdio` and the `http` alias maps to `streamable-http`,
* matching how Inspector reads its own `mcp.json`.
*/
import type { MCPConfig, StoredMCPServer } from "../types.js";
import { normalizeServerType } from "../serverList.js";
/** Parse JSON, throwing a uniform, human-readable message on failure. */
function parseJsonObject(raw: string): Record<string, unknown> {
let data: unknown;
try {
data = JSON.parse(raw);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Invalid JSON: ${detail}`);
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new Error("Expected a JSON object at the top level");
}
return data as Record<string, unknown>;
}
/**
* Normalize a raw server map (`Record<id, rawConfig>`) into canonical
* `mcpServers`. Non-object entries are skipped (a hand-edited file can carry a
* `null` or a stray scalar); each surviving entry has its `type` normalized and
* any Inspector-extension fields preserved as-is on the `StoredMCPServer`.
*/
function normalizeServerMap(
servers: Record<string, unknown>,
): Record<string, StoredMCPServer> {
const out: Record<string, StoredMCPServer> = {};
for (const [id, raw] of Object.entries(servers)) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
const normalized = normalizeServerType(
raw as Record<string, unknown> & { type?: unknown },
);
// Carry through any extra keys (headers, env, oauth, …) the source file
// had; normalizeServerType already spreads them, we just widen the type.
out[id] = normalized as StoredMCPServer;
}
return out;
}
/**
* Parse the common `{ mcpServers: { ... } }` config shape. Used by Claude
* Desktop, Cursor, Cline, and Claude Code. Throws when no `mcpServers` object
* is present.
*/
export function parseMcpServersConfig(raw: string): MCPConfig {
const data = parseJsonObject(raw);
const servers = data.mcpServers;
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
throw new Error("No 'mcpServers' object found in config file");
}
return {
mcpServers: normalizeServerMap(servers as Record<string, unknown>),
};
}
/**
* Parse VS Code's native MCP config, which uses a top-level `servers` map
* instead of `mcpServers` (and an optional `inputs` array of prompt
* placeholders we don't import — the `${input:…}` references are left intact in
* the server entries for the user to resolve via the edit form). Throws when no
* `servers` object is present.
*/
export function parseVsCodeConfig(raw: string): MCPConfig {
const data = parseJsonObject(raw);
const servers = data.servers;
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
throw new Error("No 'servers' object found in VS Code config file");
}
return {
mcpServers: normalizeServerMap(servers as Record<string, unknown>),
};
}
/**
* Parse a client config of unknown shape: try the common `{ mcpServers }` form
* first, then VS Code's `{ servers }` form. Used by the file-upload path where
* the source client isn't known up front. Surfaces the primary parser's error
* message (the common case) when neither shape matches.
*/
export function parseClientConfig(raw: string): MCPConfig {
try {
return parseMcpServersConfig(raw);
} catch (mcpErr) {
try {
return parseVsCodeConfig(raw);
} catch {
throw mcpErr instanceof Error ? mcpErr : new Error(String(mcpErr));
}
}
}