* 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:
parent
5efe9ddc7b
commit
97b1a7fbff
8 changed files with 4238 additions and 176 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
119
clients/web/src/test/integration/mcp/import/clientConfig.test.ts
Normal file
119
clients/web/src/test/integration/mcp/import/clientConfig.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
51
clients/web/src/test/integration/mcp/import/merge.test.ts
Normal file
51
clients/web/src/test/integration/mcp/import/merge.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
433
clients/web/src/test/integration/mcp/import/serverJson.test.ts
Normal file
433
clients/web/src/test/integration/mcp/import/serverJson.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
21
clients/web/src/test/integration/storage/store-id.test.ts
Normal file
21
clients/web/src/test/integration/storage/store-id.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue