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

6
.gitignore vendored
View file

@ -12,7 +12,9 @@ test-servers/build
/.playwright-mcp/
# Local debugging artifacts dropped at the repo root (e.g. attached to
# Claude Code conversations): screenshots and exported Inspector history
# dumps. Should never be part of the source tree.
# Claude Code conversations): screenshots, exported Inspector history dumps,
# and ad-hoc registry/client config fixtures used for manual import testing.
# Should never be part of the source tree.
/*.png
/inspector-history-*.json
/*.server.json

View file

@ -29,6 +29,11 @@ inspector/
│ ├── json/ # JSON utilities and parameter/argument conversion
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
│ │ │ # server.json parser, strategy registry + well-known
│ │ │ # paths, strategy-agnostic merge. Pure/isomorphic;
│ │ │ # used by the web file-upload path + /api/import-source.
│ │ ├── node/ # Node stdio transport factory
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)

View file

@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@gfazioli/mantine-border-animate": "^2.0.3",
"@hono/node-server": "^1.19.14",
"@mantine/core": "^8.3.17",
"@mantine/form": "^8.3.17",
@ -1241,6 +1242,19 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@gfazioli/mantine-border-animate": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@gfazioli/mantine-border-animate/-/mantine-border-animate-2.0.3.tgz",
"integrity": "sha512-6JmO4Cox2KAjIXWWNIewwuudTtTGS5QrcWqjgch8Kn1Zu2bwhZAk4n1y2z7YNJsnfZGVM3XcxLbdGivJcCiNMQ==",
"license": "MIT",
"peerDependencies": {
"@mantine/core": ">=7.0.0",
"@mantine/hooks": ">=7.0.0",
"@tabler/icons-react": "^3.34.0",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@ -2675,6 +2689,34 @@
}
}
},
"node_modules/@tabler/icons": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
"integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tabler/icons": "3.44.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

View file

@ -36,6 +36,7 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@gfazioli/mantine-border-animate": "^2.0.3",
"@hono/node-server": "^1.19.14",
"@mantine/core": "^8.3.17",
"@mantine/form": "^8.3.17",

View file

@ -107,6 +107,8 @@ import {
type ServerConfigModalMode,
} from "./components/groups/ServerConfigModal/ServerConfigModal";
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
import { ServerImportConfigModal } from "./components/groups/ServerImportConfigModal/ServerImportConfigModal";
import { ServerImportJsonModal } from "./components/groups/ServerImportJsonModal/ServerImportJsonModal";
import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
@ -489,6 +491,7 @@ function App() {
updateServerSettings,
removeServer,
reorderServers,
importSource,
} = useServers({
baseUrl:
typeof window !== "undefined"
@ -503,6 +506,23 @@ function App() {
mode: ServerConfigModalMode;
targetId?: string;
} | null>(null);
// Import-flow modals (#1348): "Import from client config" (other-client
// config merge) and "Import from registry config" (registry single-server
// import).
const [importConfigOpen, setImportConfigOpen] = useState(false);
const [importJsonOpen, setImportJsonOpen] = useState(false);
// Ids of freshly-added servers (manual or import) — their cards draw an
// animated border (and the first scrolls into view) until clicked. A batch
// import accumulates all of its ids here; opening an add/import modal starts a
// fresh batch. (#1348)
const [highlightedServerIds, setHighlightedServerIds] = useState<string[]>(
[],
);
const clearHighlight = useCallback(
(id: string) =>
setHighlightedServerIds((ids) => ids.filter((x) => x !== id)),
[],
);
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
string | undefined
>(undefined);
@ -2083,14 +2103,6 @@ function App() {
);
}, [logs, activeServerId]);
// Action stubs — these UI affordances exist but require additional
// wiring (server CRUD, history pinning, app sandbox round-trip, log
// export). Tracked separately; the noop keeps the prop interface
// satisfied without lying about behavior.
const todoNoop = useCallback(() => {
/* TODO: not wired yet */
}, []);
// Download the current server list as a canonical mcp.json file. Uses the
// in-memory `servers` list (kept in sync with disk by useServers' refresh-
// after-mutate flow) so there's no extra HTTP roundtrip. Serialization
@ -2156,6 +2168,20 @@ function App() {
// Submit handler for the Add / Edit / Clone modal. Add and Clone both go
// through addServer; Edit uses updateServer (which supports id rename).
// Add a server, then mark it as the freshly-added one so the list scrolls to
// it and highlights it. Used by manual add/clone and both import flows; edits
// and conflict-overwrites (updateServer) intentionally don't highlight.
// Accumulates into the current highlight batch (a multi-server import adds
// each id), deduped. The batch is reset to empty when an add/import modal
// opens (see the menu handlers).
const addServerHighlighted = useCallback(
async (id: string, config: MCPServerConfig) => {
await addServer(id, config);
setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id]));
},
[addServer],
);
// On rename of the active server, keep activeServerId pointed at the new id.
const onConfigSubmit = useCallback(
async (id: string, config: MCPServerConfig) => {
@ -2168,9 +2194,9 @@ function App() {
return;
}
// add or clone
await addServer(id, config);
await addServerHighlighted(id, config);
},
[configModal, addServer, updateServer, activeServerId],
[configModal, addServerHighlighted, updateServer, activeServerId],
);
// Derive the existingIds list the modal uses for uniqueness validation.
@ -2399,14 +2425,26 @@ function App() {
onDisconnect={() => {
void onDisconnect();
}}
onServerAdd={() => setConfigModal({ mode: "add" })}
onServerImportConfig={todoNoop}
onServerImportJson={todoNoop}
onServerAdd={() => {
setHighlightedServerIds([]);
setConfigModal({ mode: "add" });
}}
onServerImportConfig={() => {
setHighlightedServerIds([]);
setImportConfigOpen(true);
}}
onServerImportJson={() => {
setHighlightedServerIds([]);
setImportJsonOpen(true);
}}
onServerExport={onServerExport}
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
onServerSettings={(id) => setSettingsModalTargetId(id)}
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
onServerClone={(id) => setConfigModal({ mode: "clone", targetId: id })}
onServerClone={(id) => {
setHighlightedServerIds([]);
setConfigModal({ mode: "clone", targetId: id });
}}
onServerRemove={(id) => {
const target = servers.find((s) => s.id === id);
if (target) setRemoveTarget(target);
@ -2425,6 +2463,8 @@ function App() {
});
});
}}
highlightedServerIds={highlightedServerIds}
onClearHighlight={clearHighlight}
serverSupportsTaskToolCalls={
!!capabilities?.tasks?.requests?.tools?.call
}
@ -2490,6 +2530,20 @@ function App() {
onClose={() => setConfigModal(null)}
onSubmit={onConfigSubmit}
/>
<ServerImportConfigModal
opened={importConfigOpen}
existingIds={existingIds}
onClose={() => setImportConfigOpen(false)}
onFetchSource={importSource}
onAddServer={addServerHighlighted}
onUpdateServer={updateServer}
/>
<ServerImportJsonModal
opened={importJsonOpen}
existingIds={existingIds}
onClose={() => setImportJsonOpen(false)}
onAddServer={addServerHighlighted}
/>
<ServerSettingsModal
// Remount per open (and per target server) so the accordion resets to
// its initial "options" section — the body-dropped toast deep-links

View file

@ -13,12 +13,10 @@ const meta: Meta<typeof ImportServerJsonPanel> = {
component: ImportServerJsonPanel,
args: {
onJsonChange: fn(),
onValidate: fn(),
onSelectPackage: fn(),
onEnvVarChange: fn(),
onServerNameChange: fn(),
onAddServer: fn(),
onCancel: fn(),
envVars: [],
validation: [],
},

View file

@ -14,18 +14,24 @@ const emptyDraft: InspectorServerJsonDraft = {
envOverrides: {},
};
// Validation results, package selection, env vars, and the name override are
// only shown once content has been pasted/loaded, so tests for those sections
// use a draft with non-empty rawText.
const draftWithContent: InspectorServerJsonDraft = {
rawText: '{"name":"x"}',
envOverrides: {},
};
const baseHandlers = {
onJsonChange: vi.fn(),
onValidate: vi.fn(),
onSelectPackage: vi.fn(),
onEnvVarChange: vi.fn(),
onServerNameChange: vi.fn(),
onAddServer: vi.fn(),
onCancel: vi.fn(),
};
describe("ImportServerJsonPanel", () => {
it("renders the title and the action buttons", () => {
it("renders the Add Server action", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
@ -34,16 +40,12 @@ describe("ImportServerJsonPanel", () => {
envVars={[]}
/>,
);
expect(
screen.getByText("Import MCP Registry server.json"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Validate Again" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add Server" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Cancel" }),
).not.toBeInTheDocument();
});
it("invokes onJsonChange when typing in the textarea", async () => {
@ -65,27 +67,47 @@ describe("ImportServerJsonPanel", () => {
expect(onJsonChange).toHaveBeenCalledWith("x");
});
it("invokes onValidate, onCancel, and onAddServer when their buttons are clicked", async () => {
it("disables the Add Server button when addDisabled is set", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
addDisabled
draft={emptyDraft}
validation={[]}
envVars={[]}
/>,
);
expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled();
});
it("renders the File Contents control with the highlight background", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
fileContentsHighlight
draft={emptyDraft}
validation={[]}
envVars={[]}
/>,
);
expect(
screen.getByRole("button", { name: "File Contents" }),
).toBeInTheDocument();
});
it("invokes onAddServer when the Add Server button is clicked", async () => {
const user = userEvent.setup();
const onValidate = vi.fn();
const onCancel = vi.fn();
const onAddServer = vi.fn();
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
onValidate={onValidate}
onCancel={onCancel}
onAddServer={onAddServer}
draft={emptyDraft}
validation={[]}
envVars={[]}
/>,
);
await user.click(screen.getByRole("button", { name: "Validate Again" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: "Add Server" }));
expect(onValidate).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onAddServer).toHaveBeenCalledTimes(1);
});
@ -99,7 +121,7 @@ describe("ImportServerJsonPanel", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
draft={emptyDraft}
draft={draftWithContent}
validation={validation}
envVars={[]}
/>,
@ -117,7 +139,7 @@ describe("ImportServerJsonPanel", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
draft={emptyDraft}
draft={draftWithContent}
validation={[]}
envVars={[]}
packages={packages}
@ -137,7 +159,7 @@ describe("ImportServerJsonPanel", () => {
<ImportServerJsonPanel
{...baseHandlers}
onSelectPackage={onSelectPackage}
draft={emptyDraft}
draft={draftWithContent}
validation={[]}
envVars={[]}
packages={packages}
@ -167,7 +189,7 @@ describe("ImportServerJsonPanel", () => {
<ImportServerJsonPanel
{...baseHandlers}
onEnvVarChange={onEnvVarChange}
draft={emptyDraft}
draft={draftWithContent}
validation={[]}
envVars={envVars}
/>,
@ -186,12 +208,12 @@ describe("ImportServerJsonPanel", () => {
<ImportServerJsonPanel
{...baseHandlers}
onServerNameChange={onServerNameChange}
draft={emptyDraft}
draft={draftWithContent}
validation={[]}
envVars={[]}
/>,
);
const nameInput = screen.getByLabelText(/Server Name/);
const nameInput = screen.getByLabelText("Override");
await user.type(nameInput, "X");
expect(onServerNameChange).toHaveBeenCalledWith("X");
});
@ -230,15 +252,98 @@ describe("ImportServerJsonPanel", () => {
expect(onServerNameChange).toHaveBeenCalledWith("");
});
it("does not render the file picker when onPickFile is absent", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
draft={emptyDraft}
validation={[]}
envVars={[]}
/>,
);
expect(
screen.queryByRole("button", { name: /Choose file/ }),
).not.toBeInTheDocument();
});
it("renders a file picker and invokes onPickFile on upload", async () => {
const user = userEvent.setup();
const onPickFile = vi.fn();
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
onPickFile={onPickFile}
draft={emptyDraft}
validation={[]}
envVars={[]}
/>,
);
expect(
screen.getByRole("button", { name: /Choose file/ }),
).toBeInTheDocument();
const file = new File(["{}"], "server.json", {
type: "application/json",
});
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
expect(onPickFile).toHaveBeenCalledTimes(1);
});
it("renders the existing nameOverride value", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
draft={{ ...emptyDraft, nameOverride: "Custom Name" }}
draft={{ ...draftWithContent, nameOverride: "Custom Name" }}
validation={[]}
envVars={[]}
/>,
);
expect(screen.getByDisplayValue("Custom Name")).toBeInTheDocument();
});
it("shows the derived name in a read-only From configuration field", () => {
renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
draft={draftWithContent}
defaultServerName="weather"
validation={[]}
envVars={[]}
/>,
);
const fromConfig = screen.getByLabelText("From configuration");
expect(fromConfig).toHaveValue("weather");
expect(fromConfig).toHaveAttribute("readonly");
});
it("hides validation results and the name override until content is present", () => {
const validation: ValidationResult[] = [
{ type: "info", message: "Paste server.json content to validate." },
];
const { rerender } = renderWithMantine(
<ImportServerJsonPanel
{...baseHandlers}
draft={emptyDraft}
validation={validation}
envVars={[]}
/>,
);
// Empty draft: gated sections hidden.
expect(screen.queryByText("Validation Results")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Override")).not.toBeInTheDocument();
// Once content is present, they appear.
rerender(
<ImportServerJsonPanel
{...baseHandlers}
draft={draftWithContent}
validation={validation}
envVars={[]}
/>,
);
expect(screen.getByText("Validation Results")).toBeInTheDocument();
expect(screen.getByLabelText("Override")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,8 @@
import {
Accordion,
Button,
Divider,
FileButton,
Group,
Radio,
Stack,
@ -36,12 +38,34 @@ export interface ImportServerJsonPanelProps {
packages?: PackageInfo[];
envVars: EnvVarInfo[];
onJsonChange: (content: string) => void;
onValidate: () => void;
onSelectPackage: (index: number) => void;
onEnvVarChange: (name: string, value: string) => void;
onServerNameChange: (name: string) => void;
/** The id derived from the server.json `name`, shown read-only in the
* Server Name disclosure (the override field replaces it when set). */
defaultServerName?: string;
onAddServer: () => void;
onCancel: () => void;
/** Disables the Add Server button while the pasted content isn't valid. */
addDisabled?: boolean;
/**
* Controls the "File Contents" disclosure. When `onFileContentsChange` is
* provided the disclosure is controlled (the wiring layer can auto-collapse
* it after content loads); otherwise it's uncontrolled and defaults to open.
*/
fileContentsOpen?: boolean;
onFileContentsChange?: (open: boolean) => void;
/**
* When true, the "File Contents" control is painted with its hover
* background — used as a brief pre-collapse flash so the auto-collapse reads
* intentionally.
*/
fileContentsHighlight?: boolean;
/**
* Load server.json content from a file. When provided, a "Choose file…"
* button is rendered next to the paste hint; the handler reads the file and
* feeds its text back through `onJsonChange`.
*/
onPickFile?: (file: File | null) => void;
}
const validationIcons: Record<
@ -69,118 +93,178 @@ export function ImportServerJsonPanel({
packages,
envVars,
onJsonChange,
onValidate,
onSelectPackage,
onEnvVarChange,
onServerNameChange,
defaultServerName,
onAddServer,
onCancel,
addDisabled,
fileContentsOpen,
onFileContentsChange,
fileContentsHighlight,
onPickFile,
}: ImportServerJsonPanelProps) {
// Validation results + the name-override field only make sense once there's
// something to validate, so they stay hidden until content is pasted/loaded.
const hasContent = draft.rawText.trim().length > 0;
return (
<Stack gap="md">
<Title order={4}>Import MCP Registry server.json</Title>
<Group justify="space-between" align="center" wrap="nowrap">
<HintText>Paste server.json content, or load it from a file:</HintText>
{onPickFile ? (
<FileButton accept="application/json,.json" onChange={onPickFile}>
{(props) => (
<Button {...props} variant="default" size="xs">
Choose file…
</Button>
)}
</FileButton>
) : null}
</Group>
<HintText>Paste server.json content or drag and drop a file:</HintText>
<Textarea
value={draft.rawText}
onChange={(e) => onJsonChange(e.currentTarget.value)}
ff="monospace"
autosize
minRows={8}
maxRows={15}
rightSectionPointerEvents="auto"
rightSection={
draft.rawText ? (
<ClearButton onClick={() => onJsonChange("")} />
) : null
}
/>
<Divider />
<Title order={5}>Validation Results:</Title>
{validation.map((result, index) => {
const { icon, color } = validationIcons[result.type];
return (
<Group key={index} gap="xs">
<Text c={color}>{icon}</Text>
<Text size="sm">{result.message}</Text>
</Group>
);
})}
{packages && packages.length > 1 && (
<>
<Divider />
<Title order={5}>Package Selection:</Title>
<Radio.Group
value={String(draft.selectedPackageIndex ?? 0)}
onChange={(value) => onSelectPackage(Number(value))}
<Accordion
variant="separated"
transitionDuration={325}
{...(onFileContentsChange
? {
value: fileContentsOpen ? "file-contents" : null,
onChange: (value: string | null) =>
onFileContentsChange(value === "file-contents"),
}
: { defaultValue: "file-contents" })}
>
<Accordion.Item value="file-contents">
<Accordion.Control
bg={
fileContentsHighlight
? "var(--mantine-color-default-hover)"
: undefined
}
>
<Stack gap="xs">
{packages.map((pkg, index) => (
<Radio
key={index}
value={String(index)}
label={formatPackageLabel(pkg)}
/>
))}
</Stack>
</Radio.Group>
</>
)}
{envVars.length > 0 && (
<>
<Divider />
<Title order={5}>Environment Variables:</Title>
{envVars.map((envVar) => (
<TextInput
key={envVar.name}
label={envVar.name}
description={envVar.description}
withAsterisk={envVar.required}
value={envVar.value}
onChange={(e) =>
onEnvVarChange(envVar.name, e.currentTarget.value)
}
File Contents
</Accordion.Control>
<Accordion.Panel>
<Textarea
value={draft.rawText}
onChange={(e) => onJsonChange(e.currentTarget.value)}
ff="monospace"
autosize
minRows={8}
maxRows={15}
rightSectionPointerEvents="auto"
rightSection={
envVar.value ? (
<ClearButton
onClick={() => onEnvVarChange(envVar.name, "")}
/>
draft.rawText ? (
<ClearButton onClick={() => onJsonChange("")} />
) : null
}
/>
))}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{hasContent && (
<>
<Accordion variant="separated" defaultValue="validation">
<Accordion.Item value="validation">
<Accordion.Control>Validation Results</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{validation.map((result, index) => {
const { icon, color } = validationIcons[result.type];
return (
<Group key={index} gap="xs">
<Text c={color}>{icon}</Text>
<Text size="sm">{result.message}</Text>
</Group>
);
})}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{packages && packages.length > 1 && (
<>
<Divider />
<Title order={5}>Package Selection:</Title>
<Radio.Group
value={String(draft.selectedPackageIndex ?? 0)}
onChange={(value) => onSelectPackage(Number(value))}
>
<Stack gap="xs">
{packages.map((pkg, index) => (
<Radio
key={index}
value={String(index)}
label={formatPackageLabel(pkg)}
/>
))}
</Stack>
</Radio.Group>
</>
)}
{envVars.length > 0 && (
<>
<Divider />
<Title order={5}>Environment Variables:</Title>
{envVars.map((envVar) => (
<TextInput
key={envVar.name}
label={envVar.name}
description={envVar.description}
withAsterisk={envVar.required}
value={envVar.value}
onChange={(e) =>
onEnvVarChange(envVar.name, e.currentTarget.value)
}
rightSectionPointerEvents="auto"
rightSection={
envVar.value ? (
<ClearButton
onClick={() => onEnvVarChange(envVar.name, "")}
/>
) : null
}
/>
))}
</>
)}
<Accordion variant="separated" defaultValue="server-name">
<Accordion.Item value="server-name">
<Accordion.Control>Server Name</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<TextInput
label="From configuration"
description="The id derived from the server.json name."
value={defaultServerName ?? ""}
readOnly
/>
<TextInput
label="Override"
description="Optional. Used instead of the name above."
value={draft.nameOverride ?? ""}
onChange={(e) => onServerNameChange(e.currentTarget.value)}
rightSectionPointerEvents="auto"
rightSection={
draft.nameOverride ? (
<ClearButton onClick={() => onServerNameChange("")} />
) : null
}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
)}
<Divider />
<TextInput
label="Server Name (optional override)"
value={draft.nameOverride ?? ""}
onChange={(e) => onServerNameChange(e.currentTarget.value)}
rightSectionPointerEvents="auto"
rightSection={
draft.nameOverride ? (
<ClearButton onClick={() => onServerNameChange("")} />
) : null
}
/>
<Group justify="flex-end">
<Button variant="light" onClick={onValidate}>
Validate Again
<Button onClick={onAddServer} disabled={addDisabled}>
Add Server
</Button>
<Button variant="light" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onAddServer}>Add Server</Button>
</Group>
</Stack>
);

View file

@ -36,7 +36,7 @@ describe("ServerAddMenu", () => {
expect(onAddManually).toHaveBeenCalledTimes(1);
});
it("invokes onImportConfig when 'Import config' is clicked", async () => {
it("invokes onImportConfig when 'Import from client config' is clicked", async () => {
const user = userEvent.setup();
const onImportConfig = vi.fn();
renderWithMantine(
@ -48,14 +48,14 @@ describe("ServerAddMenu", () => {
);
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
const item = screen.getByRole("menuitem", {
name: /Import config/,
name: /Import from client config/,
hidden: true,
});
await user.click(item);
expect(onImportConfig).toHaveBeenCalledTimes(1);
});
it("invokes onImportServerJson when 'Import server.json' is clicked", async () => {
it("invokes onImportServerJson when 'Import from registry config' is clicked", async () => {
const user = userEvent.setup();
const onImportServerJson = vi.fn();
renderWithMantine(
@ -67,7 +67,7 @@ describe("ServerAddMenu", () => {
);
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
const item = screen.getByRole("menuitem", {
name: /Import server\.json/,
name: /Import from registry config/,
hidden: true,
});
await user.click(item);

View file

@ -20,8 +20,12 @@ export function ServerAddMenu({
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onAddManually}>+ Add manually</Menu.Item>
<Menu.Item onClick={onImportConfig}>Import config</Menu.Item>
<Menu.Item onClick={onImportServerJson}>Import server.json</Menu.Item>
<Menu.Item onClick={onImportConfig}>
Import from client config
</Menu.Item>
<Menu.Item onClick={onImportServerJson}>
Import from registry config
</Menu.Item>
</Menu.Dropdown>
</Menu>
);

View file

@ -277,4 +277,74 @@ describe("ServerCard", () => {
).toBeInTheDocument();
});
});
describe("freshly-added highlight", () => {
it("scrolls into view when highlighted", () => {
const scrollIntoView = vi.fn();
const orig = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = scrollIntoView;
try {
renderWithMantine(<ServerCard {...baseProps} highlighted />);
expect(scrollIntoView).toHaveBeenCalled();
} finally {
Element.prototype.scrollIntoView = orig;
}
});
it("does not scroll when not highlighted", () => {
const scrollIntoView = vi.fn();
const orig = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = scrollIntoView;
try {
renderWithMantine(<ServerCard {...baseProps} />);
expect(scrollIntoView).not.toHaveBeenCalled();
} finally {
Element.prototype.scrollIntoView = orig;
}
});
it("clears the highlight when the card is clicked", async () => {
const user = userEvent.setup();
const onClearHighlight = vi.fn();
renderWithMantine(
<ServerCard
{...baseProps}
highlighted
onClearHighlight={onClearHighlight}
/>,
);
await user.click(screen.getByText("My MCP Server"));
expect(onClearHighlight).toHaveBeenCalledTimes(1);
});
it("does not clear on click when not highlighted", async () => {
const user = userEvent.setup();
const onClearHighlight = vi.fn();
renderWithMantine(
<ServerCard {...baseProps} onClearHighlight={onClearHighlight} />,
);
await user.click(screen.getByText("My MCP Server"));
expect(onClearHighlight).not.toHaveBeenCalled();
});
it("still toggles the connection when the switch is clicked while highlighted", async () => {
const user = userEvent.setup();
const onToggleConnection = vi.fn();
const onClearHighlight = vi.fn();
renderWithMantine(
<ServerCard
{...baseProps}
connection={disconnected}
highlighted
onToggleConnection={onToggleConnection}
onClearHighlight={onClearHighlight}
/>,
);
// A single click both connects and dismisses the highlight — the card
// isn't remounted, so the toggle's action isn't swallowed.
await user.click(screen.getByRole("switch"));
expect(onToggleConnection).toHaveBeenCalledWith("srv-1");
expect(onClearHighlight).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
import { BorderAnimate } from "@gfazioli/mantine-border-animate";
import type {
MCPServerConfig,
ServerEntry,
@ -33,6 +34,21 @@ export interface ServerCardProps extends ServerEntry {
* drag-and-drop.
*/
dragHandle?: ReactNode;
/**
* When true, the card is freshly added: it draws an animated border (Mantine
* Border Animate) to draw the eye. Cleared by `onClearHighlight` on any click
* on the card.
*/
highlighted?: boolean;
/**
* Whether a highlighted card scrolls itself into view. When several cards are
* highlighted at once (a batch import) only the first should scroll, so the
* list jumps to the start of the batch rather than fighting over the viewport.
* Defaults to true.
*/
scrollOnHighlight?: boolean;
/** Called when a highlighted card is clicked, to dismiss the animated border. */
onClearHighlight?: () => void;
}
const HeaderLeft = Group.withProps({
@ -120,19 +136,37 @@ export function ServerCard({
compact = false,
writable = true,
dragHandle,
highlighted = false,
scrollOnHighlight = true,
onClearHighlight,
}: ServerCardProps) {
const isDimmed = activeServer !== undefined && activeServer !== id;
const rootRef = useRef<HTMLDivElement>(null);
// When freshly added, bring the card into view (it may be far down a long
// list). Only the designated card scrolls (the first of a highlighted batch).
useEffect(() => {
if (highlighted && scrollOnHighlight) {
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlighted, scrollOnHighlight]);
const transport = getTransport(config);
const commandOrUrl = getCommandOrUrl(config);
const version = info?.version;
const protocolVersion =
connection.status === "connected" ? connection.protocolVersion : undefined;
return (
const card = (
<Card
ref={rootRef}
withBorder
padding="lg"
// BorderAnimate's wrapper is display:flex, so the card must stretch to
// fill it — otherwise the card shrinks to content width while the
// animated border spans the full grid cell.
w="100%"
variant={isDimmed ? "disabled" : undefined}
onClick={highlighted ? onClearHighlight : undefined}
{...(isDimmed ? { "aria-disabled": true, inert: true } : {})}
>
<Stack gap="sm">
@ -202,4 +236,19 @@ export function ServerCard({
</Stack>
</Card>
);
// Always render the wrapper and toggle `show`/`animate` rather than
// conditionally wrapping — swapping the element type on clear would remount
// the card (and its connect toggle / buttons), swallowing the click that
// triggered the clear.
return (
<BorderAnimate
show={highlighted}
animate={highlighted}
radius="md"
w="100%"
>
{card}
</BorderAnimate>
);
}

View file

@ -51,6 +51,7 @@ interface FormState {
const SectionStack = Stack.withProps({ gap: "md" });
const FieldGrid = Stack.withProps({ gap: "sm" });
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
const ModalTitle = Text.withProps({ fw: 700, span: true });
const MODE_TITLES: Record<ServerConfigModalMode, string> = {
add: "Add server",
@ -255,7 +256,7 @@ export function ServerConfigModal({
onClose={onClose}
size="lg"
centered
title={MODE_TITLES[mode]}
title={<ModalTitle>{MODE_TITLES[mode]}</ModalTitle>}
>
<SectionStack>
<FieldGrid>

View file

@ -0,0 +1,83 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { useArgs } from "storybook/preview-api";
import type { ImportSourceResult } from "@inspector/core/mcp/import/index.js";
import {
ServerImportConfigModal,
type ServerImportConfigModalProps,
} from "./ServerImportConfigModal";
function InteractiveRender(args: ServerImportConfigModalProps) {
const [, updateArgs] = useArgs<ServerImportConfigModalProps>();
return (
<AppShell>
<AppShell.Main>
<ServerImportConfigModal
{...args}
onClose={() => {
args.onClose();
updateArgs({ opened: false });
}}
/>
</AppShell.Main>
</AppShell>
);
}
const fetchResult: ImportSourceResult = {
type: "cursor",
found: true,
path: "/home/u/.cursor/mcp.json",
searched: ["/home/u/.cursor/mcp.json"],
config: {
mcpServers: {
"new-server": { type: "stdio", command: "npx", args: ["-y", "demo"] },
existing: { type: "streamable-http", url: "https://example.com/mcp" },
},
},
};
const meta: Meta<typeof ServerImportConfigModal> = {
title: "Groups/ServerImportConfigModal",
component: ServerImportConfigModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
existingIds: ["existing"],
onClose: fn(),
onFetchSource: fn(async () => fetchResult),
onAddServer: fn(async () => undefined),
onUpdateServer: fn(async () => undefined),
},
};
export default meta;
type Story = StoryObj<typeof ServerImportConfigModal>;
export const SourcePicker: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByLabelText("Client")).toBeInTheDocument();
await expect(
body.getByRole("button", { name: "Import" }),
).toBeInTheDocument();
await expect(
body.getByRole("button", { name: /From file/ }),
).toBeInTheDocument();
},
};
export const ReviewWithConflict: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.selectOptions(
await body.findByLabelText("Client"),
"Cursor",
);
await userEvent.click(body.getByRole("button", { name: "Import" }));
await expect(await body.findByText("New servers (1)")).toBeInTheDocument();
await expect(body.getByText("Already exists (1)")).toBeInTheDocument();
},
};

View file

@ -0,0 +1,380 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import {
renderWithMantine,
screen,
waitFor,
within,
} from "../../../test/renderWithMantine";
import type { ImportSourceResult } from "@inspector/core/mcp/import/index.js";
import { ServerImportConfigModal } from "./ServerImportConfigModal";
const sourceWithConflict: ImportSourceResult = {
type: "cursor",
found: true,
path: "/home/u/.cursor/mcp.json",
searched: ["/home/u/.cursor/mcp.json"],
config: {
mcpServers: {
alpha: { type: "stdio", command: "a" },
existing: { type: "stdio", command: "e" },
},
},
};
interface Handlers {
onClose: ReturnType<typeof vi.fn>;
onFetchSource: ReturnType<typeof vi.fn>;
onAddServer: ReturnType<typeof vi.fn>;
onUpdateServer: ReturnType<typeof vi.fn>;
}
function setup(
fetchResult: ImportSourceResult | Error = sourceWithConflict,
existingIds: string[] = ["existing"],
): Handlers & { container: HTMLElement } {
const onClose = vi.fn();
const onFetchSource = vi.fn(() =>
fetchResult instanceof Error
? Promise.reject(fetchResult)
: Promise.resolve(fetchResult),
);
const onAddServer = vi.fn().mockResolvedValue(undefined);
const onUpdateServer = vi.fn().mockResolvedValue(undefined);
const { container } = renderWithMantine(
<ServerImportConfigModal
opened
existingIds={existingIds}
onClose={onClose}
onFetchSource={onFetchSource}
onAddServer={onAddServer}
onUpdateServer={onUpdateServer}
/>,
);
return { onClose, onFetchSource, onAddServer, onUpdateServer, container };
}
/** Choose a client in the source dropdown and click Import. */
async function pickClientAndImport(
user: ReturnType<typeof userEvent.setup>,
label: string,
) {
await user.selectOptions(screen.getByLabelText("Client"), label);
await user.click(screen.getByRole("button", { name: "Import" }));
}
describe("ServerImportConfigModal", () => {
it("renders nothing when closed", () => {
renderWithMantine(
<ServerImportConfigModal
opened={false}
existingIds={[]}
onClose={vi.fn()}
onFetchSource={vi.fn()}
onAddServer={vi.fn()}
onUpdateServer={vi.fn()}
/>,
);
expect(
screen.queryByText("Import from client config"),
).not.toBeInTheDocument();
});
it("renders a client dropdown, an Import button, and a file picker", () => {
setup();
// Import is disabled until a client is chosen.
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
expect(
screen.getByRole("button", { name: /From file/ }),
).toBeInTheDocument();
// The dropdown exposes every strategy as an option.
for (const label of ["Claude Desktop", "Cursor", "Cline", "VS Code"]) {
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
}
});
it("enables Import once a client is selected", async () => {
const user = userEvent.setup();
setup();
await user.selectOptions(screen.getByLabelText("Client"), "Cursor");
expect(screen.getByRole("button", { name: "Import" })).toBeEnabled();
});
it("fetches a source and shows additions + conflicts in review", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText("New servers (1)")).toBeInTheDocument(),
);
expect(h.onFetchSource).toHaveBeenCalledWith("cursor");
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.getByText("Already exists (1)")).toBeInTheDocument();
expect(screen.getByText("existing")).toBeInTheDocument();
});
it("imports additions and skips conflicts by default", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
// Default: 1 addition + 0 non-skipped conflicts.
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() =>
expect(screen.getByText("Import complete")).toBeInTheDocument(),
);
expect(h.onAddServer).toHaveBeenCalledWith("alpha", {
type: "stdio",
command: "a",
});
expect(h.onUpdateServer).not.toHaveBeenCalled();
const addedRow = screen.getByText("alpha").parentElement as HTMLElement;
expect(within(addedRow).getByText("Imported")).toBeInTheDocument();
const skippedRow = screen.getByText("existing")
.parentElement as HTMLElement;
expect(within(skippedRow).getByText("Skipped")).toBeInTheDocument();
});
it("lets the user skip an individual new server", async () => {
const user = userEvent.setup();
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
// Toggle the new server "alpha" to Skip (its row's segmented control).
const alphaRow = screen.getByText("alpha").parentElement as HTMLElement;
await user.click(within(alphaRow).getByText("Skip"));
// alpha skipped + conflict "existing" skipped by default → nothing to import.
expect(
screen.getByRole("button", { name: /Import 0 servers/ }),
).toBeDisabled();
});
it("skips a deselected new server while still importing others", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
const alphaRow = screen.getByText("alpha").parentElement as HTMLElement;
await user.click(within(alphaRow).getByText("Skip"));
// Overwrite the conflict so there's still one server to import.
await user.click(screen.getByText("Overwrite"));
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() => screen.getByText("Import complete"));
expect(h.onAddServer).not.toHaveBeenCalled();
expect(h.onUpdateServer).toHaveBeenCalledWith("existing", "existing", {
type: "stdio",
command: "e",
});
const alphaSummaryRow = screen.getByText("alpha")
.parentElement as HTMLElement;
expect(within(alphaSummaryRow).getByText("Skipped")).toBeInTheDocument();
});
it("overwrites a conflict when chosen", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
await user.click(screen.getByText("Overwrite"));
await user.click(screen.getByRole("button", { name: /Import 2 servers/ }));
await waitFor(() => screen.getByText("Import complete"));
expect(h.onUpdateServer).toHaveBeenCalledWith("existing", "existing", {
type: "stdio",
command: "e",
});
});
it("renames a conflict to the supplied id", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
await user.click(screen.getByText("Rename"));
const renameInput = screen.getByLabelText("New id for existing");
// Default rename value is existing-2.
expect((renameInput as HTMLInputElement).value).toBe("existing-2");
await user.clear(renameInput);
await user.type(renameInput, "existing-copy");
await user.click(screen.getByRole("button", { name: /Import 2 servers/ }));
await waitFor(() => screen.getByText("Import complete"));
expect(h.onAddServer).toHaveBeenCalledWith("existing-copy", {
type: "stdio",
command: "e",
});
});
it("flags an invalid/colliding rename target and blocks import", async () => {
const user = userEvent.setup();
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
await user.click(screen.getByText("Rename"));
const renameInput = screen.getByLabelText("New id for existing");
// Collides with the incoming addition "alpha".
await user.clear(renameInput);
await user.type(renameInput, "alpha");
expect(await screen.findByText(/already in use/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Import \d server/ }),
).toBeDisabled();
// Invalid characters.
await user.clear(renameInput);
await user.type(renameInput, "bad id!");
expect(
await screen.findByText(/letters, numbers, hyphens/),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Import \d server/ }),
).toBeDisabled();
// A unique, valid id clears the error and re-enables import.
await user.clear(renameInput);
await user.type(renameInput, "existing-copy");
await waitFor(() =>
expect(
screen.getByRole("button", { name: /Import 2 servers/ }),
).toBeEnabled(),
);
});
it("reports a per-server failure in the summary", async () => {
const user = userEvent.setup();
const h = setup();
h.onAddServer.mockRejectedValueOnce(new Error("boom"));
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() => screen.getByText("Import complete"));
const failedRow = screen.getByText("alpha").parentElement as HTMLElement;
expect(within(failedRow).getByText("Failed")).toBeInTheDocument();
expect(screen.getByText("boom")).toBeInTheDocument();
});
it("shows the searched-paths notice when no config is found", async () => {
const user = userEvent.setup();
setup({
type: "cursor",
found: false,
searched: ["/home/u/.cursor/mcp.json"],
});
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/No config found/)).toBeInTheDocument(),
);
});
it("shows an error when the source has a parse error", async () => {
const user = userEvent.setup();
setup({
type: "cursor",
found: true,
error: "Invalid JSON: bad",
searched: ["/home/u/.cursor/mcp.json"],
});
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/Invalid JSON: bad/)).toBeInTheDocument(),
);
});
it("shows an error when the fetch rejects", async () => {
const user = userEvent.setup();
setup(new Error("network down"));
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/network down/)).toBeInTheDocument(),
);
});
it("parses an uploaded file (mcpServers) into review", async () => {
const user = userEvent.setup();
setup();
const file = new File(
[JSON.stringify({ mcpServers: { fromfile: { command: "x" } } })],
"mcp.json",
{ type: "application/json" },
);
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
await waitFor(() =>
expect(screen.getByText("fromfile")).toBeInTheDocument(),
);
});
it("parses an uploaded VS Code file (servers) into review", async () => {
const user = userEvent.setup();
setup();
const file = new File(
[JSON.stringify({ servers: { vscodesrv: { command: "x" } } })],
"mcp.json",
{ type: "application/json" },
);
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
await waitFor(() =>
expect(screen.getByText("vscodesrv")).toBeInTheDocument(),
);
});
it("shows an error for an unparseable uploaded file", async () => {
const user = userEvent.setup();
setup();
const file = new File(["{not json"], "mcp.json", {
type: "application/json",
});
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
await waitFor(() =>
expect(screen.getByText(/Invalid JSON/)).toBeInTheDocument(),
);
});
it("reports when the selected source has no servers", async () => {
const user = userEvent.setup();
setup({
type: "cursor",
found: true,
searched: [],
config: { mcpServers: {} },
});
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/No servers found/)).toBeInTheDocument(),
);
});
it("goes back from review to the source picker", async () => {
const user = userEvent.setup();
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
await user.click(screen.getByRole("button", { name: "Back" }));
// Back to the source picker (dropdown + Import button).
expect(screen.getByLabelText("Client")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Import" })).toBeInTheDocument();
});
it("closes from the summary via Done", async () => {
const user = userEvent.setup();
const h = setup({
type: "cursor",
found: true,
searched: [],
config: { mcpServers: { only: { command: "x" } } },
});
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() => screen.getByText("Import complete"));
await user.click(screen.getByRole("button", { name: "Done" }));
expect(h.onClose).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,281 @@
import {
Alert,
Badge,
Button,
FileButton,
Group,
Loader,
Modal,
NativeSelect,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
IMPORT_STRATEGY_LIST,
type ConflictResolution,
type ImportSourceResult,
} from "@inspector/core/mcp/import/index.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import {
useImportClientConfig,
type AdditionAction,
type ImportOutcome,
} from "../../../hooks/useImportClientConfig";
export interface ServerImportConfigModalProps {
opened: boolean;
/** Ids already in the catalog — drives conflict detection + rename defaults. */
existingIds: string[];
onClose: () => void;
/** Read a client's well-known config on the backend (authed GET). */
onFetchSource: (type: string) => Promise<ImportSourceResult>;
onAddServer: (id: string, config: MCPServerConfig) => Promise<void>;
onUpdateServer: (
originalId: string,
newId: string,
config: MCPServerConfig,
) => Promise<void>;
}
const SectionStack = Stack.withProps({ gap: "md" });
const SourceRow = Group.withProps({
gap: "sm",
align: "flex-end",
wrap: "nowrap",
});
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
const RowGroup = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "sm",
});
const DimText = Text.withProps({ size: "sm", c: "dimmed" });
const ModalTitle = Text.withProps({ fw: 700, span: true });
/**
* Source-picker dropdown options, derived from the strategy registry. A leading
* empty option acts as the "nothing selected yet" placeholder (Import stays
* disabled until a real client is chosen).
*/
const SOURCE_OPTIONS = [
{ value: "", label: "Select a client…" },
...IMPORT_STRATEGY_LIST.map((s) => ({ value: s.id, label: s.label })),
];
const RESOLUTION_DATA = [
{ value: "overwrite", label: "Overwrite" },
{ value: "skip", label: "Skip" },
{ value: "rename", label: "Rename" },
];
const ADDITION_DATA = [
{ value: "import", label: "Import" },
{ value: "skip", label: "Skip" },
];
/** Display label + badge color for each per-server import outcome. */
const OUTCOME_META: Record<
ImportOutcome["status"],
{ label: string; color: string }
> = {
added: { label: "Imported", color: "green" },
overwritten: { label: "Overwritten", color: "blue" },
renamed: { label: "Renamed", color: "blue" },
skipped: { label: "Skipped", color: "gray" },
failed: { label: "Failed", color: "red" },
};
/**
* Display shell for the "Import from client config" flow (#1348): a source picker /
* file upload, a per-server review with conflict resolution, and an outcome
* summary. All orchestration lives in `useImportClientConfig`.
*/
export function ServerImportConfigModal({
opened,
existingIds,
onClose,
onFetchSource,
onAddServer,
onUpdateServer,
}: ServerImportConfigModalProps) {
const vm = useImportClientConfig({
opened,
existingIds,
onFetchSource,
onAddServer,
onUpdateServer,
});
const { plan } = vm;
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
centered
title={<ModalTitle>Import from client config</ModalTitle>}
>
<SectionStack>
{vm.error ? (
<Alert color="red" title="Import error">
{vm.error}
</Alert>
) : null}
{vm.notice ? <Alert color="yellow">{vm.notice}</Alert> : null}
{vm.phase === "select" ? (
<SectionStack>
<DimText>
Import MCP servers from another client. Choose a known client
(read on this machine) or upload its config file.
</DimText>
<SourceRow>
<NativeSelect
label="Client"
data={SOURCE_OPTIONS}
value={vm.selectedType ?? ""}
onChange={(e) =>
vm.setSelectedType(e.currentTarget.value || null)
}
flex={1}
/>
<Button
disabled={!vm.selectedType}
onClick={() => {
if (vm.selectedType) void vm.pickSource(vm.selectedType);
}}
>
Import
</Button>
<FileButton
accept="application/json,.json"
onChange={(file) => void vm.pickFile(file)}
>
{(props) => (
<Button {...props} variant="default">
From file…
</Button>
)}
</FileButton>
</SourceRow>
</SectionStack>
) : null}
{vm.phase === "loading" ? (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">Working…</Text>
</Group>
) : null}
{vm.phase === "review" && plan ? (
<SectionStack>
{plan.additions.length > 0 ? (
<Stack gap="xs">
<Text fw={600} size="sm">
New servers ({plan.additions.length})
</Text>
{plan.additions.map((a) => (
<RowGroup key={a.id}>
<Text size="sm">{a.id}</Text>
<SegmentedControl
size="xs"
data={ADDITION_DATA}
value={vm.additionActions[a.id] ?? "import"}
onChange={(value) =>
vm.setAdditionAction(a.id, value as AdditionAction)
}
/>
</RowGroup>
))}
</Stack>
) : null}
{plan.conflicts.length > 0 ? (
<Stack gap="xs">
<Text fw={600} size="sm">
Already exists ({plan.conflicts.length})
</Text>
{plan.conflicts.map((conflict) => {
const res = vm.resolutions[conflict.id];
return (
<Stack key={conflict.id} gap="xs">
<RowGroup>
<Text size="sm">{conflict.id}</Text>
<SegmentedControl
size="xs"
data={RESOLUTION_DATA}
value={res?.action ?? "skip"}
onChange={(value) =>
vm.setResolution(
conflict.id,
value as ConflictResolution,
)
}
/>
</RowGroup>
{res?.action === "rename" ? (
<TextInput
size="xs"
aria-label={`New id for ${conflict.id}`}
value={res.renameTo}
error={vm.renameErrors[conflict.id]}
onChange={(e) =>
vm.setRenameTo(conflict.id, e.currentTarget.value)
}
/>
) : null}
</Stack>
);
})}
</Stack>
) : null}
<Actions>
<Button variant="default" onClick={vm.back}>
Back
</Button>
<Button
onClick={() => void vm.runImport()}
disabled={!vm.canImport}
>
Import {vm.importCount} server{vm.importCount === 1 ? "" : "s"}
</Button>
</Actions>
</SectionStack>
) : null}
{vm.phase === "summary" ? (
<SectionStack>
<Text fw={600} size="sm">
Import complete
</Text>
{vm.outcomes.map((o) => {
const meta = OUTCOME_META[o.status];
return (
<Stack key={`${o.id}-${o.status}`} gap={2}>
<RowGroup>
<Text size="sm">{o.id}</Text>
<Badge color={meta.color} variant="light">
{meta.label}
</Badge>
</RowGroup>
{o.detail ? (
<Text size="xs" c="var(--inspector-status-error)">
{o.detail}
</Text>
) : null}
</Stack>
);
})}
<Actions>
<Button onClick={onClose}>Done</Button>
</Actions>
</SectionStack>
) : null}
</SectionStack>
</Modal>
);
}

View file

@ -0,0 +1,53 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { useArgs } from "storybook/preview-api";
import {
ServerImportJsonModal,
type ServerImportJsonModalProps,
} from "./ServerImportJsonModal";
function InteractiveRender(args: ServerImportJsonModalProps) {
const [, updateArgs] = useArgs<ServerImportJsonModalProps>();
return (
<AppShell>
<AppShell.Main>
<ServerImportJsonModal
{...args}
onClose={() => {
args.onClose();
updateArgs({ opened: false });
}}
/>
</AppShell.Main>
</AppShell>
);
}
const meta: Meta<typeof ServerImportJsonModal> = {
title: "Groups/ServerImportJsonModal",
component: ServerImportJsonModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
existingIds: [],
onClose: fn(),
onAddServer: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ServerImportJsonModal>;
export const Empty: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(
await body.findByText("Import from registry config"),
).toBeInTheDocument();
await expect(
body.getByRole("button", { name: "Add Server" }),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,341 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import {
renderWithMantine,
screen,
fireEvent,
waitFor,
} from "../../../test/renderWithMantine";
import { ServerImportJsonModal } from "./ServerImportJsonModal";
const npmJson = JSON.stringify({
name: "io.github.me/weather",
packages: [
{
registryType: "npm",
identifier: "@me/weather",
version: "1.0.0",
environmentVariables: [
{ name: "API_KEY", isRequired: true },
{ name: "LOG_LEVEL", default: "info" },
],
},
],
});
const multiPackageJson = JSON.stringify({
name: "io.github.me/multi",
packages: [
{ registryType: "npm", identifier: "@me/multi" },
{ registryType: "pypi", identifier: "multi-py" },
],
});
/** The JSON textarea is the first textbox the panel renders. */
function pasteJson(text: string) {
const textarea = screen.getAllByRole("textbox")[0];
fireEvent.change(textarea, { target: { value: text } });
}
describe("ServerImportJsonModal", () => {
it("renders nothing actionable when closed", () => {
renderWithMantine(
<ServerImportJsonModal
opened={false}
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
expect(
screen.queryByText("Import from registry config"),
).not.toBeInTheDocument();
});
it("hides validation results and the name override before any JSON is pasted", () => {
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
expect(screen.queryByText("Validation Results")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Override")).not.toBeInTheDocument();
});
it("validates a pasted npm server.json and surfaces env vars", async () => {
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
pasteJson(npmJson);
// Validation is debounced, so it appears after a short pause.
expect(
await screen.findByText(/Valid server.json for "io.github.me\/weather"/),
).toBeInTheDocument();
expect(screen.getByText(/1 runnable option/)).toBeInTheDocument();
// Env var inputs are rendered (required one + defaulted one).
expect(screen.getByLabelText(/API_KEY/)).toBeInTheDocument();
expect(screen.getByLabelText(/LOG_LEVEL/)).toBeInTheDocument();
});
it("reports a parse error for malformed JSON", async () => {
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
pasteJson("{not json");
expect(await screen.findByText(/Invalid JSON/)).toBeInTheDocument();
});
it("warns when the derived id already exists", async () => {
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={["weather"]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
pasteJson(npmJson);
expect(
await screen.findByText(/A server with id "weather" already exists/),
).toBeInTheDocument();
});
it("builds the config with env overrides and calls onAddServer", async () => {
const user = userEvent.setup();
const onAddServer = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={onClose}
onAddServer={onAddServer}
/>,
);
pasteJson(npmJson);
// The env-var inputs appear after the debounced parse.
await user.type(await screen.findByLabelText(/API_KEY/), "secret");
await user.click(screen.getByRole("button", { name: "Add Server" }));
await waitFor(() => expect(onAddServer).toHaveBeenCalledTimes(1));
const [id, config] = onAddServer.mock.calls[0];
expect(id).toBe("weather");
expect(config).toMatchObject({
type: "stdio",
command: "npx",
args: ["-y", "@me/weather@1.0.0"],
env: { API_KEY: "secret", LOG_LEVEL: "info" },
});
expect(onClose).toHaveBeenCalled();
});
it("honors a server name override", async () => {
const user = userEvent.setup();
const onAddServer = vi.fn().mockResolvedValue(undefined);
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={onAddServer}
/>,
);
pasteJson(npmJson);
await user.type(screen.getByLabelText("Override"), "my-weather");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(),
);
await user.click(screen.getByRole("button", { name: "Add Server" }));
await waitFor(() => expect(onAddServer).toHaveBeenCalled());
expect(onAddServer.mock.calls[0][0]).toBe("my-weather");
});
it("lets the user pick among multiple packages", async () => {
const user = userEvent.setup();
const onAddServer = vi.fn().mockResolvedValue(undefined);
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={onAddServer}
/>,
);
pasteJson(multiPackageJson);
// The package radios appear after the debounced parse.
await user.click(await screen.findByLabelText(/pypi: multi-py/));
await user.click(screen.getByRole("button", { name: "Add Server" }));
await waitFor(() => expect(onAddServer).toHaveBeenCalled());
expect(onAddServer.mock.calls[0][1]).toMatchObject({ command: "uvx" });
});
it("surfaces an onAddServer rejection instead of closing", async () => {
const user = userEvent.setup();
const onAddServer = vi.fn().mockRejectedValue(new Error("disk full"));
const onClose = vi.fn();
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={onClose}
onAddServer={onAddServer}
/>,
);
pasteJson(npmJson);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(),
);
await user.click(screen.getByRole("button", { name: "Add Server" }));
await waitFor(() =>
expect(screen.getByText(/disk full/)).toBeInTheDocument(),
);
expect(onClose).not.toHaveBeenCalled();
});
it("disables Add Server until valid content is present", () => {
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled();
});
it("guards against a live edit made before the debounce re-validates", async () => {
const onAddServer = vi.fn();
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={onAddServer}
/>,
);
pasteJson(npmJson);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(),
);
// Replace with invalid content; the button hasn't re-disabled yet (the
// debounce is still pending), so clicking exercises the submit-time guard.
pasteJson("{not json");
fireEvent.click(screen.getByRole("button", { name: "Add Server" }));
expect(onAddServer).not.toHaveBeenCalled();
expect(
await screen.findByText(/Fix the validation errors/),
).toBeInTheDocument();
});
it("loads server.json from a chosen file", async () => {
const user = userEvent.setup();
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
const file = new File([npmJson], "server.json", {
type: "application/json",
});
// The Modal is portaled, so query the file input from the document.
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
await waitFor(() =>
expect(
screen.getByText(/Valid server.json for "io.github.me\/weather"/),
).toBeInTheDocument(),
);
});
it("auto-collapses the File Contents disclosure after content loads", async () => {
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
const disclosure = screen.getByRole("button", { name: "File Contents" });
expect(disclosure).toHaveAttribute("aria-expanded", "true");
pasteJson(npmJson);
await waitFor(
() => expect(disclosure).toHaveAttribute("aria-expanded", "false"),
{ timeout: 3000 },
);
});
it("re-opens File Contents when the textarea is cleared", async () => {
const user = userEvent.setup();
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={vi.fn()}
/>,
);
pasteJson(npmJson);
const disclosure = screen.getByRole("button", { name: "File Contents" });
// Clear via the textarea's Clear button while still expanded.
await user.click(screen.getAllByRole("button", { name: "Clear" })[0]);
expect(disclosure).toHaveAttribute("aria-expanded", "true");
});
it("rejects an invalid id override and blocks Add Server", async () => {
const user = userEvent.setup();
const onAddServer = vi.fn();
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={vi.fn()}
onAddServer={onAddServer}
/>,
);
pasteJson(npmJson);
await user.type(screen.getByLabelText("Override"), "bad id!");
expect(
await screen.findByText(/Server id must use only letters/),
).toBeInTheDocument();
// An invalid id keeps the Add button disabled.
expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled();
expect(onAddServer).not.toHaveBeenCalled();
});
it("closes via the Escape key (no Cancel button)", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ServerImportJsonModal
opened
existingIds={[]}
onClose={onClose}
onAddServer={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: "Cancel" }),
).not.toBeInTheDocument();
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,61 @@
import { Modal, Text } from "@mantine/core";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import { useServerJsonImport } from "../../../hooks/useServerJsonImport";
import { ImportServerJsonPanel } from "../ImportServerJsonPanel/ImportServerJsonPanel";
const ModalTitle = Text.withProps({ fw: 700, span: true });
export interface ServerImportJsonModalProps {
opened: boolean;
/** Ids already in use — drives the duplicate-id warning. */
existingIds: string[];
onClose: () => void;
/** Persist the chosen server. Resolves once the catalog has been updated. */
onAddServer: (id: string, config: MCPServerConfig) => Promise<void> | void;
}
/**
* Display shell for the registry `server.json` import (#1348). All orchestration
* (parsing, debounced validation, selection, auto-collapse, submit) lives in
* `useServerJsonImport`; this component only renders the modal + dumb panel.
*/
export function ServerImportJsonModal({
opened,
existingIds,
onClose,
onAddServer,
}: ServerImportJsonModalProps) {
const vm = useServerJsonImport({ opened, existingIds, onAddServer });
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
centered
title={<ModalTitle>Import from registry config</ModalTitle>}
>
<ImportServerJsonPanel
draft={vm.draft}
validation={vm.validation}
packages={vm.packages}
envVars={vm.envVars}
addDisabled={!vm.canAdd}
fileContentsOpen={vm.fileContentsOpen}
onFileContentsChange={vm.setFileContentsOpen}
fileContentsHighlight={vm.fileContentsHighlight}
onJsonChange={vm.setRawText}
onSelectPackage={vm.selectPackage}
onEnvVarChange={vm.setEnvVar}
onServerNameChange={vm.setServerName}
defaultServerName={vm.defaultServerName}
onAddServer={() => {
void vm.submit().then((added) => {
if (added) onClose();
});
}}
onPickFile={(file) => void vm.pickFile(file)}
/>
</Modal>
);
}

View file

@ -135,4 +135,51 @@ describe("ServerListScreen", () => {
expect(screen.queryByText("Read-only session")).not.toBeInTheDocument();
});
});
describe("freshly-added highlight", () => {
it("draws an animated border on every highlighted server", () => {
const { container } = renderWithMantine(
<ServerListScreen
{...baseProps}
highlightedServerIds={["alpha", "beta"]}
/>,
);
// One animated border per highlighted card.
expect(
container.querySelectorAll(".mantine-BorderAnimate-border"),
).toHaveLength(2);
});
it("scrolls only the first highlighted card into view", () => {
const scrollIntoView = vi.fn();
const orig = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = scrollIntoView;
try {
renderWithMantine(
<ServerListScreen
{...baseProps}
highlightedServerIds={["alpha", "beta"]}
/>,
);
// Both highlighted, but only the first (alpha) scrolls.
expect(scrollIntoView).toHaveBeenCalledTimes(1);
} finally {
Element.prototype.scrollIntoView = orig;
}
});
it("clears the highlight for the clicked card by id", async () => {
const user = userEvent.setup();
const onClearHighlight = vi.fn();
renderWithMantine(
<ServerListScreen
{...baseProps}
highlightedServerIds={["alpha", "beta"]}
onClearHighlight={onClearHighlight}
/>,
);
await user.click(screen.getByText("Beta"));
expect(onClearHighlight).toHaveBeenCalledWith("beta");
});
});
});

Some files were not shown because too many files have changed in this diff Show more