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
8 changed files with 4238 additions and 176 deletions

View file

@ -123,6 +123,29 @@ describe("useServers", () => {
});
});
it("importSource returns a result for a known source type", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
const res = await result.current.importSource("cursor");
expect(res.type).toBe("cursor");
expect(typeof res.found).toBe("boolean");
expect(Array.isArray(res.searched)).toBe(true);
});
it("importSource throws on an unknown source type", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
await expect(result.current.importSource("bogus")).rejects.toThrow(
/Unknown import source/,
);
});
it("addServer throws on 409 (duplicate id)", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),

View file

@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
parseMcpServersConfig,
parseVsCodeConfig,
parseClientConfig,
} from "@inspector/core/mcp/import/clientConfig.js";
describe("parseMcpServersConfig", () => {
it("parses a standard { mcpServers } file and normalizes types", () => {
const raw = JSON.stringify({
mcpServers: {
local: { command: "npx", args: ["-y", "server-everything"] },
remote: { type: "http", url: "https://example.com/mcp" },
sse: { type: "sse", url: "https://example.com/sse" },
},
});
const config = parseMcpServersConfig(raw);
// Missing type defaults to stdio.
expect(config.mcpServers.local.type).toBe("stdio");
// "http" alias normalizes to streamable-http.
expect(config.mcpServers.remote.type).toBe("streamable-http");
expect(config.mcpServers.sse.type).toBe("sse");
});
it("preserves extension fields (env, headers) on entries", () => {
const raw = JSON.stringify({
mcpServers: {
s: {
command: "node",
args: ["server.js"],
env: { API_KEY: "abc" },
},
},
});
const config = parseMcpServersConfig(raw);
const entry = config.mcpServers.s;
expect(entry.type).toBe("stdio");
if (entry.type === "stdio") {
expect(entry.env).toEqual({ API_KEY: "abc" });
}
});
it("skips non-object server entries", () => {
const raw = JSON.stringify({
mcpServers: {
good: { command: "node" },
bad: null,
alsoBad: 42,
arr: [],
},
});
const config = parseMcpServersConfig(raw);
expect(Object.keys(config.mcpServers)).toEqual(["good"]);
});
it("throws on invalid JSON", () => {
expect(() => parseMcpServersConfig("{not json")).toThrow(/Invalid JSON/);
});
it("throws when the top level is not an object", () => {
expect(() => parseMcpServersConfig("[]")).toThrow(/top level/);
expect(() => parseMcpServersConfig("42")).toThrow(/top level/);
});
it("throws when mcpServers is missing or not an object", () => {
expect(() => parseMcpServersConfig("{}")).toThrow(/mcpServers/);
expect(() => parseMcpServersConfig('{"mcpServers": []}')).toThrow(
/mcpServers/,
);
});
});
describe("parseVsCodeConfig", () => {
it("parses VS Code's top-level servers map", () => {
const raw = JSON.stringify({
servers: {
gh: { type: "http", url: "https://api.example.com/mcp" },
local: { command: "uvx", args: ["my-server"] },
},
inputs: [{ id: "token", type: "promptString" }],
});
const config = parseVsCodeConfig(raw);
expect(config.mcpServers.gh.type).toBe("streamable-http");
expect(config.mcpServers.local.type).toBe("stdio");
});
it("throws when there is no servers object", () => {
expect(() => parseVsCodeConfig("{}")).toThrow(/servers/);
expect(() => parseVsCodeConfig('{"servers": 1}')).toThrow(/servers/);
});
it("throws on invalid JSON", () => {
expect(() => parseVsCodeConfig("nope")).toThrow(/Invalid JSON/);
});
});
describe("parseClientConfig (unknown shape)", () => {
it("parses the { mcpServers } shape", () => {
const config = parseClientConfig(
JSON.stringify({ mcpServers: { a: { command: "node" } } }),
);
expect(config.mcpServers.a.type).toBe("stdio");
});
it("falls back to the VS Code { servers } shape", () => {
const config = parseClientConfig(
JSON.stringify({ servers: { b: { command: "node" } } }),
);
expect(config.mcpServers.b.type).toBe("stdio");
});
it("surfaces the mcpServers error when neither shape matches", () => {
expect(() => parseClientConfig("{}")).toThrow(/mcpServers/);
});
it("surfaces invalid JSON", () => {
expect(() => parseClientConfig("nope")).toThrow(/Invalid JSON/);
});
});

View file

@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { planImport, uniqueId } from "@inspector/core/mcp/import/merge.js";
import type { MCPConfig } from "@inspector/core/mcp/types.js";
const incoming: MCPConfig = {
mcpServers: {
alpha: { type: "stdio", command: "a" },
beta: { type: "stdio", command: "b" },
gamma: { type: "stdio", command: "g" },
},
};
describe("planImport", () => {
it("splits incoming servers into additions and conflicts", () => {
const plan = planImport(incoming, ["beta"]);
expect(plan.additions.map((a) => a.id)).toEqual(["alpha", "gamma"]);
expect(plan.conflicts.map((c) => c.id)).toEqual(["beta"]);
expect(plan.conflicts[0].config).toEqual({ type: "stdio", command: "b" });
});
it("treats everything as an addition when nothing collides", () => {
const plan = planImport(incoming, []);
expect(plan.additions).toHaveLength(3);
expect(plan.conflicts).toHaveLength(0);
});
it("preserves source map order", () => {
const plan = planImport(incoming, []);
expect(plan.additions.map((a) => a.id)).toEqual(["alpha", "beta", "gamma"]);
});
});
describe("uniqueId", () => {
it("returns the base when free", () => {
expect(uniqueId("github", ["slack"])).toBe("github");
});
it("suffixes -2, -3 … when taken", () => {
expect(uniqueId("github", ["github"])).toBe("github-2");
expect(uniqueId("github", ["github", "github-2"])).toBe("github-3");
});
it("sanitizes the base to the allowed charset", () => {
expect(uniqueId("My Server!", [])).toBe("My-Server");
});
it("falls back to 'server' for an all-invalid base", () => {
expect(uniqueId("!!!", [])).toBe("server");
expect(uniqueId("!!!", ["server"])).toBe("server-2");
});
});

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { resolveImportSource } from "@inspector/core/mcp/import/resolveSource.js";
const HOME = "/home/alice";
const cursorPath = "/home/alice/.cursor/mcp.json";
describe("resolveImportSource", () => {
it("returns null for an unknown strategy id", () => {
expect(resolveImportSource("bogus", "linux", HOME, () => null)).toBeNull();
});
it("returns found:false with the searched paths when no file exists", () => {
const result = resolveImportSource("cursor", "linux", HOME, () => null);
expect(result).toEqual({
type: "cursor",
found: false,
searched: [cursorPath],
});
});
it("parses the first existing well-known path", () => {
const raw = JSON.stringify({ mcpServers: { s: { command: "node" } } });
const result = resolveImportSource("cursor", "linux", HOME, (p) =>
p === cursorPath ? raw : null,
);
expect(result).toMatchObject({
type: "cursor",
found: true,
path: cursorPath,
});
expect(result?.config?.mcpServers.s.type).toBe("stdio");
});
it("walks to the second path when the first does not exist (Cline)", () => {
const fallback = "/home/alice/Documents/Cline/MCP/cline_mcp_settings.json";
const raw = JSON.stringify({ mcpServers: { c: { command: "node" } } });
const result = resolveImportSource("cline", "linux", HOME, (p) =>
p === fallback ? raw : null,
);
expect(result?.found).toBe(true);
expect(result?.path).toBe(fallback);
});
it("returns found:true with an error when the file will not parse", () => {
const result = resolveImportSource(
"cursor",
"linux",
HOME,
() => "{not json",
);
expect(result?.found).toBe(true);
expect(result?.path).toBe(cursorPath);
expect(result?.error).toMatch(/Invalid JSON/);
expect(result?.config).toBeUndefined();
});
it("returns found:true with a read error when the reader throws", () => {
const result = resolveImportSource("cursor", "linux", HOME, () => {
throw new Error("EACCES");
});
expect(result?.found).toBe(true);
expect(result?.error).toMatch(/Failed to read .*EACCES/);
});
});

View file

@ -0,0 +1,433 @@
import { describe, it, expect } from "vitest";
import {
parseServerJson,
buildServerConfig,
buildServerConfigForSelection,
deriveServerId,
resolveServerId,
selectServerJsonOption,
} from "@inspector/core/mcp/import/serverJson.js";
const npmServerJson = JSON.stringify({
$schema:
"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
name: "io.github.my-username/weather",
description: "An MCP server for weather information.",
version: "1.0.1",
packages: [
{
registryType: "npm",
identifier: "@my-username/mcp-weather-server",
version: "1.0.1",
transport: { type: "stdio" },
environmentVariables: [
{
description: "Your API key",
isRequired: true,
isSecret: true,
name: "YOUR_API_KEY",
},
{ name: "LOG_LEVEL", default: "info" },
],
},
],
});
describe("deriveServerId", () => {
it("takes the last path segment and sanitizes it", () => {
expect(deriveServerId("io.github.user/weather")).toBe("weather");
expect(deriveServerId("com.example/My Server!")).toBe("My-Server");
expect(deriveServerId("plain-name")).toBe("plain-name");
});
it("falls back to 'server' when nothing usable remains", () => {
expect(deriveServerId("io.github.user/")).toBe("server");
expect(deriveServerId("///")).toBe("server");
});
});
describe("parseServerJson — packages", () => {
it("parses an npm package into an npx stdio config", () => {
const parsed = parseServerJson(npmServerJson);
expect(parsed.serverName).toBe("weather");
expect(parsed.fullName).toBe("io.github.my-username/weather");
expect(parsed.options).toHaveLength(1);
const opt = parsed.options[0];
expect(opt.registryType).toBe("npm");
expect(opt.runtimeHint).toBe("npx");
expect(opt.baseConfig).toMatchObject({
type: "stdio",
command: "npx",
args: ["-y", "@my-username/mcp-weather-server@1.0.1"],
});
// Only the var with a default goes into base env; the required secret is
// surfaced for the user to fill.
if (opt.baseConfig.type === "stdio") {
expect(opt.baseConfig.env).toEqual({ LOG_LEVEL: "info" });
}
expect(opt.envVars).toEqual([
{
name: "YOUR_API_KEY",
description: "Your API key",
required: true,
isSecret: true,
},
{ name: "LOG_LEVEL", required: false, default: "info" },
]);
});
it("maps pypi/oci/nuget registry types to their runtimes", () => {
const make = (registryType: string, identifier: string) =>
parseServerJson(
JSON.stringify({
name: "com.example/x",
packages: [{ registryType, identifier, version: "2.0.0" }],
}),
).options[0];
expect(make("pypi", "weather-py").baseConfig).toMatchObject({
command: "uvx",
args: ["weather-py"],
});
expect(make("oci", "docker.io/u/app:1.0.0").baseConfig).toMatchObject({
command: "docker",
args: ["run", "-i", "--rm", "docker.io/u/app:1.0.0"],
});
expect(make("nuget", "Acme.Mcp").baseConfig).toMatchObject({
command: "dnx",
args: ["Acme.Mcp@2.0.0"],
});
});
it("resolves runtime and package arguments in order", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/args",
packages: [
{
registryType: "npm",
identifier: "pkg",
runtimeArguments: [
{ type: "named", name: "--node-opt", value: "x" },
],
packageArguments: [
{ type: "positional", value: "subcommand" },
{ type: "named", name: "--port", value: "8080" },
{ type: "named", name: "--flag" },
{ type: "named", value: "orphan" }, // named without a name → skipped
{ type: "positional" },
"not-an-object",
],
},
],
}),
);
expect(parsed.options[0].baseConfig).toMatchObject({
command: "npx",
args: [
"--node-opt",
"x",
"-y",
"pkg",
"subcommand",
"--port",
"8080",
"--flag",
],
});
});
it("appends docker package args after the image", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/d",
packages: [
{
registryType: "oci",
identifier: "img:1",
packageArguments: [{ type: "positional", value: "--inside" }],
},
],
}),
);
expect(parsed.options[0].baseConfig).toMatchObject({
args: ["run", "-i", "--rm", "img:1", "--inside"],
});
});
it("forwards declared env vars into OCI containers via -e flags", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/d",
packages: [
{
registryType: "oci",
identifier: "img:1",
environmentVariables: [
{ name: "API_KEY", isRequired: true },
{ name: "LOG", default: "info" },
],
},
],
}),
);
const opt = parsed.options[0];
// -e KEY flags go before the image so docker forwards them into the
// container (sourced from config.env at connect time).
expect(opt.baseConfig).toMatchObject({
command: "docker",
args: ["run", "-i", "--rm", "-e", "API_KEY", "-e", "LOG", "img:1"],
});
if (opt.baseConfig.type === "stdio") {
expect(opt.baseConfig.env).toEqual({ LOG: "info" });
}
});
it("skips unsupported (mcpb) and malformed packages", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/mix",
packages: [
{ registryType: "mcpb", identifier: "https://x/y.mcpb" },
{ registryType: "npm" }, // missing identifier
null,
{ registryType: "npm", identifier: "good" },
],
}),
);
expect(parsed.options).toHaveLength(1);
expect(parsed.options[0].identifier).toBe("good");
});
});
describe("parseServerJson — remotes", () => {
it("parses streamable-http and sse remotes", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/acme",
remotes: [
{ type: "streamable-http", url: "https://a.example.com/mcp" },
{ type: "sse", url: "https://a.example.com/sse" },
],
}),
);
expect(parsed.options).toHaveLength(2);
// Remotes are labeled "remote", with the transport carried in runtimeHint.
expect(parsed.options[0].registryType).toBe("remote");
expect(parsed.options[0].runtimeHint).toBe("streamable-http");
expect(parsed.options[1].registryType).toBe("remote");
expect(parsed.options[1].runtimeHint).toBe("sse");
expect(parsed.options[0].baseConfig).toEqual({
type: "streamable-http",
url: "https://a.example.com/mcp",
});
expect(parsed.options[1].baseConfig).toEqual({
type: "sse",
url: "https://a.example.com/sse",
});
});
it("substitutes URL template variables with their defaults", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/multi",
remotes: [
{
type: "streamable-http",
url: "https://api.example.com/{region}/mcp",
variables: { region: { default: "us-east-1" } },
},
],
}),
);
expect(parsed.options[0].identifier).toBe(
"https://api.example.com/us-east-1/mcp",
);
});
it("leaves a template token intact when no default is declared", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/t",
remotes: [
{
type: "streamable-http",
url: "https://{tenant}.example.com/mcp",
variables: { tenant: { isRequired: true } },
},
],
}),
);
expect(parsed.options[0].identifier).toBe(
"https://{tenant}.example.com/mcp",
);
});
it("skips remotes without a url", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/r",
remotes: [
{ type: "streamable-http" },
{ type: "sse", url: "https://x/sse" },
],
}),
);
expect(parsed.options).toHaveLength(1);
});
});
describe("parseServerJson — wrapper + snake_case + errors", () => {
it("accepts a { server: {...} } wrapper", () => {
const parsed = parseServerJson(
JSON.stringify({
server: {
name: "com.example/wrapped",
packages: [{ registryType: "npm", identifier: "p" }],
},
}),
);
expect(parsed.fullName).toBe("com.example/wrapped");
});
it("reads snake_case registry fields", () => {
const parsed = parseServerJson(
JSON.stringify({
name: "com.example/snake",
packages: [
{
registry_type: "npm",
identifier: "snake-pkg",
environment_variables: [
{ name: "TOKEN", is_required: true, is_secret: true },
],
package_arguments: [{ type: "positional", value: "go" }],
},
],
}),
);
const opt = parsed.options[0];
expect(opt.registryType).toBe("npm");
expect(opt.baseConfig).toMatchObject({ args: ["-y", "snake-pkg", "go"] });
expect(opt.envVars[0]).toMatchObject({ required: true, isSecret: true });
});
it("throws on invalid JSON, non-object, and missing name", () => {
expect(() => parseServerJson("nope")).toThrow(/Invalid JSON/);
expect(() => parseServerJson("[]")).toThrow(/top level/);
expect(() => parseServerJson("{}")).toThrow(/name/);
});
it("throws when there is no runnable package or remote", () => {
expect(() =>
parseServerJson(
JSON.stringify({
name: "com.example/empty",
packages: [{ registryType: "mcpb", identifier: "x.mcpb" }],
}),
),
).toThrow(/No runnable/);
});
});
describe("buildServerConfig", () => {
it("merges env overrides over declared defaults", () => {
const opt = parseServerJson(npmServerJson).options[0];
const config = buildServerConfig(opt, {
YOUR_API_KEY: "secret",
LOG_LEVEL: "debug",
});
expect(config).toMatchObject({
type: "stdio",
command: "npx",
env: { YOUR_API_KEY: "secret", LOG_LEVEL: "debug" },
});
});
it("drops env keys whose override is empty", () => {
const opt = parseServerJson(npmServerJson).options[0];
const config = buildServerConfig(opt, { LOG_LEVEL: "" });
if (config.type === "stdio") {
expect(config.env).toBeUndefined();
}
});
it("returns a remote config unchanged (ignores env overrides)", () => {
const opt = parseServerJson(
JSON.stringify({
name: "com.example/r",
remotes: [{ type: "streamable-http", url: "https://x/mcp" }],
}),
).options[0];
const config = buildServerConfig(opt, { ANYTHING: "x" });
expect(config).toEqual({ type: "streamable-http", url: "https://x/mcp" });
});
});
describe("resolveServerId", () => {
it("uses the trimmed override when present, else the derived name", () => {
const parsed = parseServerJson(npmServerJson);
expect(resolveServerId(parsed)).toBe("weather");
expect(resolveServerId(parsed, " custom ")).toBe("custom");
expect(resolveServerId(parsed, " ")).toBe("weather");
});
});
describe("selectServerJsonOption", () => {
const multi = JSON.stringify({
name: "com.example/multi",
packages: [
{ registryType: "npm", identifier: "@me/a" },
{ registryType: "pypi", identifier: "b" },
],
});
it("defaults to the first option with a valid, free id", () => {
const sel = selectServerJsonOption(parseServerJson(npmServerJson));
expect(sel.selectedIndex).toBe(0);
expect(sel.selectedOption.registryType).toBe("npm");
expect(sel.serverId).toBe("weather");
expect(sel.idIsValid).toBe(true);
expect(sel.idIsDuplicate).toBe(false);
});
it("clamps an out-of-range selected index", () => {
const sel = selectServerJsonOption(parseServerJson(multi), {
selectedIndex: 5,
});
expect(sel.selectedIndex).toBe(1);
expect(sel.selectedOption.registryType).toBe("pypi");
});
it("flags an invalid id override", () => {
const sel = selectServerJsonOption(parseServerJson(npmServerJson), {
idOverride: "bad id!",
});
expect(sel.serverId).toBe("bad id!");
expect(sel.idIsValid).toBe(false);
});
it("flags a duplicate id against existingIds", () => {
const sel = selectServerJsonOption(parseServerJson(npmServerJson), {
existingIds: ["weather"],
});
expect(sel.idIsDuplicate).toBe(true);
});
});
describe("buildServerConfigForSelection", () => {
it("merges only the selected option's declared env vars", () => {
const opt = parseServerJson(npmServerJson).options[0];
const config = buildServerConfigForSelection(opt, {
YOUR_API_KEY: "secret",
NOT_DECLARED: "ignored",
});
expect(config).toMatchObject({
type: "stdio",
env: { YOUR_API_KEY: "secret", LOG_LEVEL: "info" },
});
if (config.type === "stdio") {
expect(config.env?.NOT_DECLARED).toBeUndefined();
}
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import {
IMPORT_STRATEGIES,
IMPORT_STRATEGY_LIST,
getImportStrategy,
} from "@inspector/core/mcp/import/strategies.js";
const HOME = "/home/alice";
describe("import strategy registry", () => {
it("exposes claude-desktop, cursor, cline, and vscode", () => {
expect(IMPORT_STRATEGY_LIST.map((s) => s.id)).toEqual([
"claude-desktop",
"cursor",
"cline",
"vscode",
]);
});
it("resolves strategies by id and returns undefined for unknown", () => {
expect(getImportStrategy("cursor")?.label).toBe("Cursor");
expect(getImportStrategy("nope")).toBeUndefined();
expect(IMPORT_STRATEGIES["vscode"].label).toBe("VS Code");
});
it("delegates parse to the shared mcpServers parser for standard clients", () => {
const raw = JSON.stringify({ mcpServers: { s: { command: "node" } } });
const config = getImportStrategy("claude-desktop")!.parse(raw);
expect(config.mcpServers.s.type).toBe("stdio");
});
it("delegates parse to the VS Code parser for vscode", () => {
const raw = JSON.stringify({ servers: { s: { command: "node" } } });
const config = getImportStrategy("vscode")!.parse(raw);
expect(config.mcpServers.s.type).toBe("stdio");
});
});
describe("platform-aware default paths", () => {
it("Claude Desktop per platform", () => {
expect(
getImportStrategy("claude-desktop")!.defaultPaths("darwin", HOME),
).toEqual([
"/home/alice/Library/Application Support/Claude/claude_desktop_config.json",
]);
expect(
getImportStrategy("claude-desktop")!.defaultPaths("linux", HOME),
).toEqual(["/home/alice/.config/Claude/claude_desktop_config.json"]);
expect(
getImportStrategy("claude-desktop")!.defaultPaths(
"win32",
"C:/Users/alice",
),
).toEqual([
"C:/Users/alice/AppData/Roaming/Claude/claude_desktop_config.json",
]);
});
it("Cursor is home-relative on every platform", () => {
expect(getImportStrategy("cursor")!.defaultPaths("darwin", HOME)).toEqual([
"/home/alice/.cursor/mcp.json",
]);
});
it("Cline lists the editor globalStorage path then the Documents fallback", () => {
const paths = getImportStrategy("cline")!.defaultPaths("darwin", HOME);
expect(paths).toHaveLength(2);
expect(paths[0]).toContain(
"Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
);
expect(paths[1]).toBe(
"/home/alice/Documents/Cline/MCP/cline_mcp_settings.json",
);
});
it("VS Code native mcp.json per platform", () => {
expect(getImportStrategy("vscode")!.defaultPaths("linux", HOME)).toEqual([
"/home/alice/.config/Code/User/mcp.json",
]);
expect(getImportStrategy("vscode")!.defaultPaths("darwin", HOME)).toEqual([
"/home/alice/Library/Application Support/Code/User/mcp.json",
]);
});
});

View file

@ -95,6 +95,36 @@ describe("/api/servers routes", () => {
await teardown(h);
});
describe("GET /api/import-source", () => {
it("rejects an unknown source type with 400", async () => {
const res = await fetch(`${h.baseUrl}/api/import-source?type=bogus`);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/Unknown import source/);
});
it("rejects a missing source type with 400", async () => {
const res = await fetch(`${h.baseUrl}/api/import-source`);
expect(res.status).toBe(400);
});
it("returns a 200 result for a known source type", async () => {
// Host-independent: whether the well-known file exists or not, a known
// strategy returns 200 with the resolved type + searched paths.
const res = await fetch(`${h.baseUrl}/api/import-source?type=cursor`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
type: string;
found: boolean;
searched: string[];
};
expect(body.type).toBe("cursor");
expect(typeof body.found).toBe("boolean");
expect(Array.isArray(body.searched)).toBe(true);
expect(body.searched.length).toBeGreaterThan(0);
});
});
describe("GET /api/servers", () => {
it("writes the seed config and returns it on first read (file absent)", async () => {
expect(existsSync(h.configPath)).toBe(false);

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { validateStoreId } from "@inspector/core/storage/store-id.js";
import { validateStoreId as reexported } from "@inspector/core/storage/store-io.js";
describe("validateStoreId", () => {
it("accepts alphanumerics, hyphens, and underscores", () => {
expect(validateStoreId("my-server_1")).toBe(true);
expect(validateStoreId("Server")).toBe(true);
});
it("rejects empty or out-of-charset ids", () => {
expect(validateStoreId("")).toBe(false);
expect(validateStoreId("bad id")).toBe(false);
expect(validateStoreId("nope!")).toBe(false);
expect(validateStoreId("a/b")).toBe(false);
});
it("is re-exported from store-io for back-compat", () => {
expect(reexported).toBe(validateStoreId);
});
});