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>
This commit is contained in:
Cliff Hall 2026-06-23 19:52:08 -04:00 • committed by GitHub
commit 97b1a7fbff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 4238 additions and 176 deletions

View file

@ -0,0 +1,105 @@
/**
* 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));
}
}
}

40
core/mcp/import/index.ts Normal file
View file

@ -0,0 +1,40 @@
/**
* Barrel for the MCP config import layer (#1348). Re-exports the strategy
* registry, the client-config + registry-server.json parsers, and the
* strategy-agnostic merge helpers. Pure + isomorphic — safe to import from the
* browser and from the Node backend route alike.
*/
export type { ImportStrategy, ImportSourceResult } from "./types.js";
export {
parseMcpServersConfig,
parseVsCodeConfig,
parseClientConfig,
} from "./clientConfig.js";
export {
parseServerJson,
buildServerConfig,
buildServerConfigForSelection,
deriveServerId,
resolveServerId,
selectServerJsonOption,
} from "./serverJson.js";
export type {
ParsedServerJson,
ServerJsonOption,
ServerJsonEnvVar,
ServerJsonSelection,
} from "./serverJson.js";
export {
IMPORT_STRATEGIES,
IMPORT_STRATEGY_LIST,
getImportStrategy,
} from "./strategies.js";
export { resolveImportSource } from "./resolveSource.js";
export type { ImportFileReader } from "./resolveSource.js";
export { planImport, uniqueId } from "./merge.js";
export type {
ImportPlan,
ImportAddition,
ImportConflict,
ConflictResolution,
} from "./merge.js";

64
core/mcp/import/merge.ts Normal file
View file

@ -0,0 +1,64 @@
/**
* Strategy-agnostic merge helpers (#1348). Operate purely on the canonical
* `MCPConfig` a strategy produces, so they don't care which client the servers
* came from. The UI uses `planImport` to split incoming servers into
* conflict-free additions vs. id collisions, then drives the per-conflict
* resolution (overwrite / skip / rename) and calls the add/update endpoints.
*/
import type { MCPConfig, StoredMCPServer } from "../types.js";
/** How the user chose to resolve a single id collision. */
export type ConflictResolution = "overwrite" | "skip" | "rename";
/** One incoming server whose id already exists in the current catalog. */
export interface ImportConflict {
id: string;
config: StoredMCPServer;
}
/** One incoming server whose id is free to add as-is. */
export interface ImportAddition {
id: string;
config: StoredMCPServer;
}
export interface ImportPlan {
/** Servers whose ids are not yet taken — safe to add directly. */
additions: ImportAddition[];
/** Servers whose ids collide with an existing one — need a resolution. */
conflicts: ImportConflict[];
}
/**
* Split an incoming config into conflict-free additions and id collisions
* against the set of ids already present. Order of the source map is preserved.
*/
export function planImport(
incoming: MCPConfig,
existingIds: readonly string[],
): ImportPlan {
const taken = new Set(existingIds);
const additions: ImportAddition[] = [];
const conflicts: ImportConflict[] = [];
for (const [id, config] of Object.entries(incoming.mcpServers)) {
if (taken.has(id)) conflicts.push({ id, config });
else additions.push({ id, config });
}
return { additions, conflicts };
}
/**
* Produce an id derived from `base` that isn't in `taken`. Tries `base`,
* `base-2`, `base-3`, … and sanitizes `base` to the allowed id charset
* (`[A-Za-z0-9_-]`) first so a renamed id always validates server-side.
*/
export function uniqueId(base: string, taken: readonly string[]): string {
const takenSet = new Set(taken);
const cleaned =
base.replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "") || "server";
if (!takenSet.has(cleaned)) return cleaned;
for (let n = 2; ; n++) {
const candidate = `${cleaned}-${n}`;
if (!takenSet.has(candidate)) return candidate;
}
}

View file

@ -0,0 +1,57 @@
/**
* Resolve an import source (#1348): walk a strategy's well-known paths, read the
* first that exists, and parse it into the canonical `MCPConfig`. Kept pure by
* dependency injection — `platform`, `homeDir`, and a `readFile` reader are
* passed in — so it's fully unit-testable without touching the real filesystem
* or home directory. The Node backend route supplies `process.platform`,
* `os.homedir()`, and an `fs`-backed reader.
*/
import type { ImportSourceResult } from "./types.js";
import { getImportStrategy } from "./strategies.js";
/**
* Read a file's contents, or return `null` when the path does not exist. Throw
* to signal a genuine read failure (permission denied, etc.) — that surfaces as
* `{ found: true, error }` so the UI can offer the file-upload fallback.
*/
export type ImportFileReader = (path: string) => string | null;
/**
* Resolve a strategy's well-known config into an `ImportSourceResult`. Returns
* `null` for an unknown strategy id so the caller can map it to a 400. The first
* existing path wins; a read or parse failure short-circuits with an `error`.
*/
export function resolveImportSource(
type: string,
platform: NodeJS.Platform,
homeDir: string,
readFile: ImportFileReader,
): ImportSourceResult | null {
const strategy = getImportStrategy(type);
if (!strategy) return null;
const searched = strategy.defaultPaths(platform, homeDir);
for (const path of searched) {
let raw: string | null;
try {
raw = readFile(path);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
type,
found: true,
path,
error: `Failed to read ${path}: ${msg}`,
searched,
};
}
if (raw === null) continue;
try {
const config = strategy.parse(raw);
return { type, found: true, path, config, searched };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { type, found: true, path, error: msg, searched };
}
}
return { type, found: false, searched };
}

View file

@ -0,0 +1,420 @@
/**
* Parser for the official MCP Registry `server.json` format (the file
* `mcp-publisher init` generates and publishes). Distinct from the client-config
* parsers: a `server.json` describes a *single* server via one or more runnable
* `packages` and/or `remotes`, and carries publish-time metadata
* (`environmentVariables`, `packageArguments`, URL `variables`) that has to be
* mapped onto a runnable Inspector `MCPServerConfig`.
*
* Pure + isomorphic (no Node deps). The web wiring layer (`useServerJsonImport`)
* turns the parsed options into the props the dumb `ImportServerJsonPanel`
* renders, then calls `buildServerConfig` once the user has filled in env vars.
*
* Schema reference: https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json
* The published `server.json` file uses camelCase keys (`registryType`,
* `runtimeHint`, `environmentVariables`, …); the registry REST API has
* historically returned the same fields in snake_case. We read both so a blob
* copied from either source imports cleanly.
*/
import type { MCPServerConfig, StdioServerConfig } from "../types.js";
import { validateStoreId } from "../../storage/store-id.js";
/** A registry package's environment-variable declaration. */
export interface ServerJsonEnvVar {
name: string;
description?: string;
required: boolean;
/** Declared default/value, pre-filled into the form when present. */
default?: string;
isSecret?: boolean;
}
/**
* One runnable way to launch the server — either a package (npm/pypi/oci/nuget)
* resolved to a stdio command, or a remote (streamable-http/sse) URL. The dumb
* panel renders these as the package-selection radios; `buildServerConfig`
* turns the chosen one + env overrides into the final config.
*/
export interface ServerJsonOption {
/** Registry/transport label source: "npm" | "pypi" | "oci" | "nuget" | "streamable-http" | "sse". */
registryType: string;
/** Package identifier or remote URL — shown in the radio label. */
identifier: string;
/** The runtime/transport used to launch it (e.g. "npx", "uvx", "docker", "streamable-http"). */
runtimeHint: string;
/**
* The runnable config with all statically-known values resolved (runtime
* command, args, declared env defaults, URL variable defaults). Env vars the
* user must supply are surfaced via `envVars` and merged in by
* `buildServerConfig`.
*/
baseConfig: MCPServerConfig;
/** Env vars declared for this option (empty for remotes). */
envVars: ServerJsonEnvVar[];
}
export interface ParsedServerJson {
/** Sanitized default server id, derived from the last segment of `name`. */
serverName: string;
/** The full registry name (e.g. "io.github.foo/weather"). */
fullName: string;
/** Runnable launch options, packages first then remotes. */
options: ServerJsonOption[];
}
/** Read a field by its camelCase or snake_case key. */
function pick(
obj: Record<string, unknown>,
camel: string,
snake: string,
): unknown {
return obj[camel] !== undefined ? obj[camel] : obj[snake];
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asObject(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
/**
* Derive a safe Inspector server id from a registry name. The registry name is
* a reverse-DNS-ish string ("io.github.user/weather"); we take the segment
* after the last "/" and strip anything outside `[A-Za-z0-9_-]`. Falls back to
* "server" when nothing usable remains.
*/
export function deriveServerId(fullName: string): string {
const lastSegment = fullName.split("/").pop() ?? fullName;
const cleaned = lastSegment
.replace(/[^A-Za-z0-9_-]/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "server";
}
/** Parse an `environmentVariables` array into our env-var descriptors. */
function parseEnvVars(rawList: unknown[]): ServerJsonEnvVar[] {
const out: ServerJsonEnvVar[] = [];
for (const entry of rawList) {
const obj = asObject(entry);
if (!obj) continue;
const name = asString(obj.name);
if (!name) continue;
const required = Boolean(pick(obj, "isRequired", "is_required"));
const isSecret = Boolean(pick(obj, "isSecret", "is_secret"));
// The schema's `value` is a *fixed* value and `default` a suggestion; we
// prefill both as the (editable) input value. We don't currently lock a
// fixed `value` against editing — acceptable for an import preview the user
// reviews before saving.
const def = asString(obj.default) ?? asString(obj.value);
out.push({
name,
description: asString(obj.description),
required,
...(def !== undefined ? { default: def } : {}),
...(isSecret ? { isSecret: true } : {}),
});
}
return out;
}
/**
* Resolve a registry `Argument` list (positional/named) into argv strings.
* Best-effort: positional args contribute their `value`/`default`; named args
* contribute their flag `name` plus any `value`/`default`. Arguments with
* neither a value nor a default are skipped (the user can add them via the edit
* form afterward); advanced features (`isRepeated`, `variables`) are ignored.
*/
function resolveArguments(rawList: unknown[]): string[] {
const out: string[] = [];
for (const entry of rawList) {
const obj = asObject(entry);
if (!obj) continue;
const type = asString(obj.type) ?? "positional";
const value = asString(obj.value) ?? asString(obj.default);
if (type === "named") {
const name = asString(obj.name);
if (!name) continue;
out.push(name);
if (value !== undefined) out.push(value);
} else {
if (value !== undefined) out.push(value);
}
}
return out;
}
/** Build the env map for a package: declared defaults only (overrides applied later). */
function defaultEnv(envVars: ServerJsonEnvVar[]): Record<string, string> {
const env: Record<string, string> = {};
for (const v of envVars) {
if (v.default !== undefined) env[v.name] = v.default;
}
return env;
}
/** runtime command + base args for a given registry package type, or null if unsupported. */
function runtimeForPackage(
registryType: string,
identifier: string,
version: string | undefined,
): { command: string; runtimeHint: string; ref: string } | null {
const versioned = (sep: string) =>
version ? `${identifier}${sep}${version}` : identifier;
switch (registryType) {
case "npm":
return { command: "npx", runtimeHint: "npx", ref: versioned("@") };
case "pypi":
return { command: "uvx", runtimeHint: "uvx", ref: identifier };
case "oci":
return { command: "docker", runtimeHint: "docker", ref: identifier };
case "nuget":
return { command: "dnx", runtimeHint: "dnx", ref: versioned("@") };
// mcpb artifacts are downloadable bundles, not a runnable command — skip.
default:
return null;
}
}
/** Map a single `packages[]` entry to a launch option, or null if unsupported. */
function parsePackage(raw: Record<string, unknown>): ServerJsonOption | null {
const registryType =
asString(pick(raw, "registryType", "registry_type")) ?? "";
const identifier = asString(raw.identifier) ?? "";
if (!registryType || !identifier) return null;
const version = asString(raw.version);
const runtime = runtimeForPackage(registryType, identifier, version);
if (!runtime) return null;
const envVars = parseEnvVars(
asArray(pick(raw, "environmentVariables", "environment_variables")),
);
const runtimeArgs = resolveArguments(
asArray(pick(raw, "runtimeArguments", "runtime_arguments")),
);
const packageArgs = resolveArguments(
asArray(pick(raw, "packageArguments", "package_arguments")),
);
const env = defaultEnv(envVars);
// docker needs `run -i --rm` before the image; package args go to the
// container after the image. Declared env vars must be forwarded into the
// container with `-e KEY` flags — otherwise they'd only set the `docker` CLI
// process's environment (via config.env) and never reach the server. `-e KEY`
// (no value) tells docker to pass KEY through from that process env, which is
// where buildServerConfig merges the user-supplied values. Other runtimes
// inherit config.env directly, so they need no extra flags.
const dockerEnvArgs = envVars.flatMap((v) => ["-e", v.name]);
const args =
runtime.command === "docker"
? [
"run",
"-i",
"--rm",
...runtimeArgs,
...dockerEnvArgs,
runtime.ref,
...packageArgs,
]
: registryType === "npm"
? [...runtimeArgs, "-y", runtime.ref, ...packageArgs]
: [...runtimeArgs, runtime.ref, ...packageArgs];
const baseConfig: StdioServerConfig = {
type: "stdio",
command: runtime.command,
...(args.length > 0 ? { args } : {}),
...(Object.keys(env).length > 0 ? { env } : {}),
};
return {
registryType,
identifier,
runtimeHint: runtime.runtimeHint,
baseConfig,
envVars,
};
}
/** Substitute `{var}` template tokens in a remote URL with their declared defaults. */
function resolveRemoteUrl(
url: string,
variables: Record<string, unknown> | undefined,
): string {
if (!variables) return url;
return url.replace(/\{([^}]+)\}/g, (match, name: string) => {
const v = asObject(variables[name]);
const def = v ? asString(v.default) : undefined;
return def ?? match;
});
}
/** Map a single `remotes[]` entry to a launch option, or null if unsupported. */
function parseRemote(raw: Record<string, unknown>): ServerJsonOption | null {
const type = asString(raw.type);
const url = asString(raw.url);
if (!url) return null;
const transport = type === "sse" ? "sse" : "streamable-http";
const resolvedUrl = resolveRemoteUrl(url, asObject(raw.variables));
const baseConfig: MCPServerConfig =
transport === "sse"
? { type: "sse", url: resolvedUrl }
: { type: "streamable-http", url: resolvedUrl };
// Label remotes as "remote" (the install method); the transport variant
// (streamable-http / sse) is carried in runtimeHint so the picker shows
// e.g. "remote: https://… (streamable-http)".
return {
registryType: "remote",
identifier: resolvedUrl,
runtimeHint: transport,
baseConfig,
envVars: [],
};
}
/**
* Parse a registry `server.json` blob into its runnable options. Accepts either
* a bare server object or a `{ server: {...} }` wrapper (some registry API
* responses nest it). Throws when the blob is not a server object or exposes no
* runnable package/remote.
*/
export function parseServerJson(raw: string): ParsedServerJson {
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}`);
}
const top = asObject(data);
if (!top) throw new Error("Expected a JSON object at the top level");
const server = asObject(top.server) ?? top;
const fullName = asString(server.name);
if (!fullName) {
throw new Error("server.json is missing a 'name' field");
}
const options: ServerJsonOption[] = [];
for (const pkg of asArray(server.packages)) {
const obj = asObject(pkg);
if (!obj) continue;
const option = parsePackage(obj);
if (option) options.push(option);
}
for (const remote of asArray(server.remotes)) {
const obj = asObject(remote);
if (!obj) continue;
const option = parseRemote(obj);
if (option) options.push(option);
}
if (options.length === 0) {
throw new Error(
"No runnable package or remote found in server.json (mcpb-only or unsupported registry types are not importable)",
);
}
return {
serverName: deriveServerId(fullName),
fullName,
options,
};
}
/**
* Build the final runnable config for a chosen option, merging user-supplied
* env overrides over the declared defaults. Empty-string values are dropped so
* an untouched required var doesn't persist as `KEY=""`. Remotes ignore env
* overrides (their config carries no env).
*/
export function buildServerConfig(
option: ServerJsonOption,
envOverrides: Record<string, string>,
): MCPServerConfig {
if (option.baseConfig.type !== "stdio") {
return { ...option.baseConfig };
}
const merged: Record<string, string> = { ...(option.baseConfig.env ?? {}) };
for (const [key, value] of Object.entries(envOverrides)) {
if (value === "") delete merged[key];
else merged[key] = value;
}
// Drop the original `env` from the base (via destructure) so it isn't spread
// back in; re-add it only when the merged map is non-empty. This keeps an
// entry whose overrides emptied everything out from persisting a stale env.
const { env: _baseEnv, ...base } = option.baseConfig;
return {
...base,
...(Object.keys(merged).length > 0 ? { env: merged } : {}),
};
}
/** The id a parsed server.json will be saved under: the override (if any) wins. */
export function resolveServerId(
parsed: ParsedServerJson,
idOverride?: string,
): string {
const trimmed = (idOverride ?? "").trim();
return trimmed || parsed.serverName;
}
/** The chosen launch option plus the resulting id and its validity. */
export interface ServerJsonSelection {
selectedIndex: number;
selectedOption: ServerJsonOption;
serverId: string;
idIsValid: boolean;
idIsDuplicate: boolean;
}
/**
* Resolve which option a parsed server.json will import as, the id it will use,
* and whether that id is valid + free. Pure, so both the web wiring and a future
* CLI/TUI import can share the selection logic. `selectedIndex` is clamped to the
* available options.
*/
export function selectServerJsonOption(
parsed: ParsedServerJson,
opts: {
selectedIndex?: number;
idOverride?: string;
existingIds?: readonly string[];
} = {},
): ServerJsonSelection {
const selectedIndex = Math.min(
opts.selectedIndex ?? 0,
Math.max(parsed.options.length - 1, 0),
);
const serverId = resolveServerId(parsed, opts.idOverride);
return {
selectedIndex,
selectedOption: parsed.options[selectedIndex],
serverId,
idIsValid: validateStoreId(serverId),
idIsDuplicate: (opts.existingIds ?? []).includes(serverId),
};
}
/**
* Merge the user's env overrides for the *selected* option only (env vars the
* option doesn't declare are ignored) and build the runnable config.
*/
export function buildServerConfigForSelection(
option: ServerJsonOption,
envOverrides: Record<string, string>,
): MCPServerConfig {
const overrides: Record<string, string> = {};
for (const v of option.envVars) {
const value = envOverrides[v.name];
if (value !== undefined) overrides[v.name] = value;
}
return buildServerConfig(option, overrides);
}

View file

@ -0,0 +1,116 @@
/**
* Registry of import strategies (#1348). Each entry knows its well-known
* config locations per platform and how to parse that client's file into the
* canonical `MCPConfig`. The source picker enumerates `IMPORT_STRATEGY_LIST`;
* the backend `GET /api/import-source` route and the browser file-upload path
* both resolve a strategy via `getImportStrategy(id)`.
*
* Adding a new client = add one entry here (+ a fixture test). Clients that use
* the standard `{ mcpServers: {...} }` shape reuse `parseMcpServersConfig` and
* only differ by `defaultPaths`; VS Code's `servers`/`inputs` variant gets its
* own `parseVsCodeConfig`.
*
* Pure + isomorphic: `defaultPaths` receives `platform` + `homeDir` so this file
* never imports Node `os`/`path`. Paths are joined with `/`, which Node `fs`
* accepts on every platform.
*/
import type { ImportStrategy } from "./types.js";
import { parseMcpServersConfig, parseVsCodeConfig } from "./clientConfig.js";
/** Join path segments with `/`, trimming a trailing slash on the base. */
function join(...segments: string[]): string {
return segments
.map((s, i) =>
i === 0 ? s.replace(/\/+$/, "") : s.replace(/^\/+|\/+$/g, ""),
)
.filter((s) => s.length > 0)
.join("/");
}
/** Windows `%APPDATA%` (Roaming) under the user's home directory. */
function appData(homeDir: string): string {
return join(homeDir, "AppData", "Roaming");
}
/**
* Per-platform application-support directory for a given app folder name.
* macOS: `~/Library/Application Support/<app>`; Windows: `%APPDATA%\<app>`;
* Linux/other: `~/.config/<app>`.
*/
function appSupportDir(
platform: NodeJS.Platform,
homeDir: string,
app: string,
): string {
if (platform === "darwin") {
return join(homeDir, "Library", "Application Support", app);
}
if (platform === "win32") {
return join(appData(homeDir), app);
}
return join(homeDir, ".config", app);
}
const claudeDesktop: ImportStrategy = {
id: "claude-desktop",
label: "Claude Desktop",
defaultPaths: (platform, homeDir) => [
join(
appSupportDir(platform, homeDir, "Claude"),
"claude_desktop_config.json",
),
],
parse: parseMcpServersConfig,
};
const cursor: ImportStrategy = {
id: "cursor",
label: "Cursor",
defaultPaths: (_platform, homeDir) => [join(homeDir, ".cursor", "mcp.json")],
parse: parseMcpServersConfig,
};
const cline: ImportStrategy = {
id: "cline",
label: "Cline",
defaultPaths: (platform, homeDir) => [
// Cline (VS Code extension) global settings live under the editor's
// globalStorage; the documented cross-platform fallback is ~/Documents.
join(
appSupportDir(platform, homeDir, "Code"),
"User",
"globalStorage",
"saoudrizwan.claude-dev",
"settings",
"cline_mcp_settings.json",
),
join(homeDir, "Documents", "Cline", "MCP", "cline_mcp_settings.json"),
],
parse: parseMcpServersConfig,
};
const vscode: ImportStrategy = {
id: "vscode",
label: "VS Code",
defaultPaths: (platform, homeDir) => [
join(appSupportDir(platform, homeDir, "Code"), "User", "mcp.json"),
],
parse: parseVsCodeConfig,
};
/** All strategies in source-picker display order. */
export const IMPORT_STRATEGY_LIST: readonly ImportStrategy[] = [
claudeDesktop,
cursor,
cline,
vscode,
];
/** Map of `id → ImportStrategy` for resolution. */
export const IMPORT_STRATEGIES: Readonly<Record<string, ImportStrategy>> =
Object.fromEntries(IMPORT_STRATEGY_LIST.map((s) => [s.id, s]));
/** Resolve a strategy by id, or undefined for an unknown id. */
export function getImportStrategy(id: string): ImportStrategy | undefined {
return IMPORT_STRATEGIES[id];
}

60
core/mcp/import/types.ts Normal file
View file

@ -0,0 +1,60 @@
/**
* Import strategy pattern (#1348). Each strategy knows how to identify itself,
* provide platform-aware well-known locations for another MCP client's config
* file, and parse that file's raw contents into the canonical Inspector
* `MCPConfig` (`{ mcpServers: { ... } }`) shape.
*
* Everything here is pure and isomorphic — no Node `fs`/`os`/`path` imports — so
* it can be reused by the browser (file-upload parse), the web backend
* (well-known-path read), and any future CLI/TUI import. The Node-only pieces
* (resolving the home dir, reading the file) live in the backend route that
* consumes these strategies; `defaultPaths` takes `platform` + `homeDir` as
* inputs rather than reaching for `os.homedir()` itself.
*/
import type { MCPConfig } from "../types.js";
/**
* A single importable source (Claude Desktop, Cursor, Cline, VS Code, …). The
* registry in `strategies.ts` maps `id → ImportStrategy`; the source picker
* enumerates the registry and the import flow resolves against it. Adding a new
* client is a small, isolated unit of work: add one strategy + register it.
*/
export interface ImportStrategy {
/** Stable id used by the source picker and the backend `?type=` query. */
id: string;
/** Human-readable label shown in the source picker. */
label: string;
/**
* Platform-aware well-known locations, in priority order. Pure: the caller
* supplies the running platform and the user's home directory so this never
* touches Node APIs. Paths are joined with `/`, which Node's `fs` accepts on
* every platform (including Windows).
*/
defaultPaths(platform: NodeJS.Platform, homeDir: string): string[];
/**
* Parse raw file contents into the canonical `MCPConfig`. Throws on malformed
* input (bad JSON, missing server map) — callers surface the message.
*/
parse(raw: string): MCPConfig;
}
/**
* Response shape for `GET /api/import-source?type=<id>`. `found` is false when
* none of the strategy's well-known paths exist on the backend host; `error` is
* set when a path was found but its contents could not be parsed (so the UI can
* offer the file-upload fallback instead of silently showing nothing).
*/
export interface ImportSourceResult {
/** The strategy id that was resolved. */
type: string;
/** True when one of the well-known paths existed and was read. */
found: boolean;
/** The path that was read (only when `found`). */
path?: string;
/** Parsed canonical config (only when `found` and parse succeeded). */
config?: MCPConfig;
/** Parse error message (only when `found` but parse failed). */
error?: string;
/** The well-known paths that were searched, in priority order. */
searched: string[];
}