* 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
44 changed files with 4238 additions and 176 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -12,7 +12,9 @@ test-servers/build
|
||||||
/.playwright-mcp/
|
/.playwright-mcp/
|
||||||
|
|
||||||
# Local debugging artifacts dropped at the repo root (e.g. attached to
|
# Local debugging artifacts dropped at the repo root (e.g. attached to
|
||||||
# Claude Code conversations): screenshots and exported Inspector history
|
# Claude Code conversations): screenshots, exported Inspector history dumps,
|
||||||
# dumps. Should never be part of the source tree.
|
# and ad-hoc registry/client config fixtures used for manual import testing.
|
||||||
|
# Should never be part of the source tree.
|
||||||
/*.png
|
/*.png
|
||||||
/inspector-history-*.json
|
/inspector-history-*.json
|
||||||
|
/*.server.json
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ inspector/
|
||||||
│ ├── json/ # JSON utilities and parameter/argument conversion
|
│ ├── json/ # JSON utilities and parameter/argument conversion
|
||||||
│ ├── logging/ # Silent pino logger singleton
|
│ ├── logging/ # Silent pino logger singleton
|
||||||
│ ├── mcp/ # InspectorClient runtime + state stores
|
│ ├── 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
|
│ │ ├── node/ # Node stdio transport factory
|
||||||
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
||||||
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
||||||
|
|
|
||||||
42
clients/web/package-lock.json
generated
42
clients/web/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@gfazioli/mantine-border-animate": "^2.0.3",
|
||||||
"@hono/node-server": "^1.19.14",
|
"@hono/node-server": "^1.19.14",
|
||||||
"@mantine/core": "^8.3.17",
|
"@mantine/core": "^8.3.17",
|
||||||
"@mantine/form": "^8.3.17",
|
"@mantine/form": "^8.3.17",
|
||||||
|
|
@ -1241,6 +1242,19 @@
|
||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@gfazioli/mantine-border-animate": "^2.0.3",
|
||||||
"@hono/node-server": "^1.19.14",
|
"@hono/node-server": "^1.19.14",
|
||||||
"@mantine/core": "^8.3.17",
|
"@mantine/core": "^8.3.17",
|
||||||
"@mantine/form": "^8.3.17",
|
"@mantine/form": "^8.3.17",
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,8 @@ import {
|
||||||
type ServerConfigModalMode,
|
type ServerConfigModalMode,
|
||||||
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
||||||
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
|
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 { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
|
||||||
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
|
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
|
||||||
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
|
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
|
||||||
|
|
@ -489,6 +491,7 @@ function App() {
|
||||||
updateServerSettings,
|
updateServerSettings,
|
||||||
removeServer,
|
removeServer,
|
||||||
reorderServers,
|
reorderServers,
|
||||||
|
importSource,
|
||||||
} = useServers({
|
} = useServers({
|
||||||
baseUrl:
|
baseUrl:
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
|
|
@ -503,6 +506,23 @@ function App() {
|
||||||
mode: ServerConfigModalMode;
|
mode: ServerConfigModalMode;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
} | null>(null);
|
} | 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<
|
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
@ -2083,14 +2103,6 @@ function App() {
|
||||||
);
|
);
|
||||||
}, [logs, activeServerId]);
|
}, [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
|
// 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-
|
// in-memory `servers` list (kept in sync with disk by useServers' refresh-
|
||||||
// after-mutate flow) so there's no extra HTTP roundtrip. Serialization
|
// 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
|
// Submit handler for the Add / Edit / Clone modal. Add and Clone both go
|
||||||
// through addServer; Edit uses updateServer (which supports id rename).
|
// 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.
|
// On rename of the active server, keep activeServerId pointed at the new id.
|
||||||
const onConfigSubmit = useCallback(
|
const onConfigSubmit = useCallback(
|
||||||
async (id: string, config: MCPServerConfig) => {
|
async (id: string, config: MCPServerConfig) => {
|
||||||
|
|
@ -2168,9 +2194,9 @@ function App() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// add or clone
|
// 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.
|
// Derive the existingIds list the modal uses for uniqueness validation.
|
||||||
|
|
@ -2399,14 +2425,26 @@ function App() {
|
||||||
onDisconnect={() => {
|
onDisconnect={() => {
|
||||||
void onDisconnect();
|
void onDisconnect();
|
||||||
}}
|
}}
|
||||||
onServerAdd={() => setConfigModal({ mode: "add" })}
|
onServerAdd={() => {
|
||||||
onServerImportConfig={todoNoop}
|
setHighlightedServerIds([]);
|
||||||
onServerImportJson={todoNoop}
|
setConfigModal({ mode: "add" });
|
||||||
|
}}
|
||||||
|
onServerImportConfig={() => {
|
||||||
|
setHighlightedServerIds([]);
|
||||||
|
setImportConfigOpen(true);
|
||||||
|
}}
|
||||||
|
onServerImportJson={() => {
|
||||||
|
setHighlightedServerIds([]);
|
||||||
|
setImportJsonOpen(true);
|
||||||
|
}}
|
||||||
onServerExport={onServerExport}
|
onServerExport={onServerExport}
|
||||||
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
|
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
|
||||||
onServerSettings={(id) => setSettingsModalTargetId(id)}
|
onServerSettings={(id) => setSettingsModalTargetId(id)}
|
||||||
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: 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) => {
|
onServerRemove={(id) => {
|
||||||
const target = servers.find((s) => s.id === id);
|
const target = servers.find((s) => s.id === id);
|
||||||
if (target) setRemoveTarget(target);
|
if (target) setRemoveTarget(target);
|
||||||
|
|
@ -2425,6 +2463,8 @@ function App() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
highlightedServerIds={highlightedServerIds}
|
||||||
|
onClearHighlight={clearHighlight}
|
||||||
serverSupportsTaskToolCalls={
|
serverSupportsTaskToolCalls={
|
||||||
!!capabilities?.tasks?.requests?.tools?.call
|
!!capabilities?.tasks?.requests?.tools?.call
|
||||||
}
|
}
|
||||||
|
|
@ -2490,6 +2530,20 @@ function App() {
|
||||||
onClose={() => setConfigModal(null)}
|
onClose={() => setConfigModal(null)}
|
||||||
onSubmit={onConfigSubmit}
|
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
|
<ServerSettingsModal
|
||||||
// Remount per open (and per target server) so the accordion resets to
|
// Remount per open (and per target server) so the accordion resets to
|
||||||
// its initial "options" section — the body-dropped toast deep-links
|
// its initial "options" section — the body-dropped toast deep-links
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,10 @@ const meta: Meta<typeof ImportServerJsonPanel> = {
|
||||||
component: ImportServerJsonPanel,
|
component: ImportServerJsonPanel,
|
||||||
args: {
|
args: {
|
||||||
onJsonChange: fn(),
|
onJsonChange: fn(),
|
||||||
onValidate: fn(),
|
|
||||||
onSelectPackage: fn(),
|
onSelectPackage: fn(),
|
||||||
onEnvVarChange: fn(),
|
onEnvVarChange: fn(),
|
||||||
onServerNameChange: fn(),
|
onServerNameChange: fn(),
|
||||||
onAddServer: fn(),
|
onAddServer: fn(),
|
||||||
onCancel: fn(),
|
|
||||||
envVars: [],
|
envVars: [],
|
||||||
validation: [],
|
validation: [],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,24 @@ const emptyDraft: InspectorServerJsonDraft = {
|
||||||
envOverrides: {},
|
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 = {
|
const baseHandlers = {
|
||||||
onJsonChange: vi.fn(),
|
onJsonChange: vi.fn(),
|
||||||
onValidate: vi.fn(),
|
|
||||||
onSelectPackage: vi.fn(),
|
onSelectPackage: vi.fn(),
|
||||||
onEnvVarChange: vi.fn(),
|
onEnvVarChange: vi.fn(),
|
||||||
onServerNameChange: vi.fn(),
|
onServerNameChange: vi.fn(),
|
||||||
onAddServer: vi.fn(),
|
onAddServer: vi.fn(),
|
||||||
onCancel: vi.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ImportServerJsonPanel", () => {
|
describe("ImportServerJsonPanel", () => {
|
||||||
it("renders the title and the action buttons", () => {
|
it("renders the Add Server action", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
|
|
@ -34,16 +40,12 @@ describe("ImportServerJsonPanel", () => {
|
||||||
envVars={[]}
|
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(
|
expect(
|
||||||
screen.getByRole("button", { name: "Add Server" }),
|
screen.getByRole("button", { name: "Add Server" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Cancel" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onJsonChange when typing in the textarea", async () => {
|
it("invokes onJsonChange when typing in the textarea", async () => {
|
||||||
|
|
@ -65,27 +67,47 @@ describe("ImportServerJsonPanel", () => {
|
||||||
expect(onJsonChange).toHaveBeenCalledWith("x");
|
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 user = userEvent.setup();
|
||||||
const onValidate = vi.fn();
|
|
||||||
const onCancel = vi.fn();
|
|
||||||
const onAddServer = vi.fn();
|
const onAddServer = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
onValidate={onValidate}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onAddServer={onAddServer}
|
onAddServer={onAddServer}
|
||||||
draft={emptyDraft}
|
draft={emptyDraft}
|
||||||
validation={[]}
|
validation={[]}
|
||||||
envVars={[]}
|
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" }));
|
await user.click(screen.getByRole("button", { name: "Add Server" }));
|
||||||
expect(onValidate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onAddServer).toHaveBeenCalledTimes(1);
|
expect(onAddServer).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -99,7 +121,7 @@ describe("ImportServerJsonPanel", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
draft={emptyDraft}
|
draft={draftWithContent}
|
||||||
validation={validation}
|
validation={validation}
|
||||||
envVars={[]}
|
envVars={[]}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -117,7 +139,7 @@ describe("ImportServerJsonPanel", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
draft={emptyDraft}
|
draft={draftWithContent}
|
||||||
validation={[]}
|
validation={[]}
|
||||||
envVars={[]}
|
envVars={[]}
|
||||||
packages={packages}
|
packages={packages}
|
||||||
|
|
@ -137,7 +159,7 @@ describe("ImportServerJsonPanel", () => {
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
onSelectPackage={onSelectPackage}
|
onSelectPackage={onSelectPackage}
|
||||||
draft={emptyDraft}
|
draft={draftWithContent}
|
||||||
validation={[]}
|
validation={[]}
|
||||||
envVars={[]}
|
envVars={[]}
|
||||||
packages={packages}
|
packages={packages}
|
||||||
|
|
@ -167,7 +189,7 @@ describe("ImportServerJsonPanel", () => {
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
onEnvVarChange={onEnvVarChange}
|
onEnvVarChange={onEnvVarChange}
|
||||||
draft={emptyDraft}
|
draft={draftWithContent}
|
||||||
validation={[]}
|
validation={[]}
|
||||||
envVars={envVars}
|
envVars={envVars}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -186,12 +208,12 @@ describe("ImportServerJsonPanel", () => {
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
onServerNameChange={onServerNameChange}
|
onServerNameChange={onServerNameChange}
|
||||||
draft={emptyDraft}
|
draft={draftWithContent}
|
||||||
validation={[]}
|
validation={[]}
|
||||||
envVars={[]}
|
envVars={[]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const nameInput = screen.getByLabelText(/Server Name/);
|
const nameInput = screen.getByLabelText("Override");
|
||||||
await user.type(nameInput, "X");
|
await user.type(nameInput, "X");
|
||||||
expect(onServerNameChange).toHaveBeenCalledWith("X");
|
expect(onServerNameChange).toHaveBeenCalledWith("X");
|
||||||
});
|
});
|
||||||
|
|
@ -230,15 +252,98 @@ describe("ImportServerJsonPanel", () => {
|
||||||
expect(onServerNameChange).toHaveBeenCalledWith("");
|
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", () => {
|
it("renders the existing nameOverride value", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ImportServerJsonPanel
|
<ImportServerJsonPanel
|
||||||
{...baseHandlers}
|
{...baseHandlers}
|
||||||
draft={{ ...emptyDraft, nameOverride: "Custom Name" }}
|
draft={{ ...draftWithContent, nameOverride: "Custom Name" }}
|
||||||
validation={[]}
|
validation={[]}
|
||||||
envVars={[]}
|
envVars={[]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByDisplayValue("Custom Name")).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
|
FileButton,
|
||||||
Group,
|
Group,
|
||||||
Radio,
|
Radio,
|
||||||
Stack,
|
Stack,
|
||||||
|
|
@ -36,12 +38,34 @@ export interface ImportServerJsonPanelProps {
|
||||||
packages?: PackageInfo[];
|
packages?: PackageInfo[];
|
||||||
envVars: EnvVarInfo[];
|
envVars: EnvVarInfo[];
|
||||||
onJsonChange: (content: string) => void;
|
onJsonChange: (content: string) => void;
|
||||||
onValidate: () => void;
|
|
||||||
onSelectPackage: (index: number) => void;
|
onSelectPackage: (index: number) => void;
|
||||||
onEnvVarChange: (name: string, value: string) => void;
|
onEnvVarChange: (name: string, value: string) => void;
|
||||||
onServerNameChange: (name: 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;
|
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<
|
const validationIcons: Record<
|
||||||
|
|
@ -69,118 +93,178 @@ export function ImportServerJsonPanel({
|
||||||
packages,
|
packages,
|
||||||
envVars,
|
envVars,
|
||||||
onJsonChange,
|
onJsonChange,
|
||||||
onValidate,
|
|
||||||
onSelectPackage,
|
onSelectPackage,
|
||||||
onEnvVarChange,
|
onEnvVarChange,
|
||||||
onServerNameChange,
|
onServerNameChange,
|
||||||
|
defaultServerName,
|
||||||
onAddServer,
|
onAddServer,
|
||||||
onCancel,
|
addDisabled,
|
||||||
|
fileContentsOpen,
|
||||||
|
onFileContentsChange,
|
||||||
|
fileContentsHighlight,
|
||||||
|
onPickFile,
|
||||||
}: ImportServerJsonPanelProps) {
|
}: 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 (
|
return (
|
||||||
<Stack gap="md">
|
<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>
|
<Accordion
|
||||||
|
variant="separated"
|
||||||
<Textarea
|
transitionDuration={325}
|
||||||
value={draft.rawText}
|
{...(onFileContentsChange
|
||||||
onChange={(e) => onJsonChange(e.currentTarget.value)}
|
? {
|
||||||
ff="monospace"
|
value: fileContentsOpen ? "file-contents" : null,
|
||||||
autosize
|
onChange: (value: string | null) =>
|
||||||
minRows={8}
|
onFileContentsChange(value === "file-contents"),
|
||||||
maxRows={15}
|
}
|
||||||
rightSectionPointerEvents="auto"
|
: { defaultValue: "file-contents" })}
|
||||||
rightSection={
|
>
|
||||||
draft.rawText ? (
|
<Accordion.Item value="file-contents">
|
||||||
<ClearButton onClick={() => onJsonChange("")} />
|
<Accordion.Control
|
||||||
) : null
|
bg={
|
||||||
}
|
fileContentsHighlight
|
||||||
/>
|
? "var(--mantine-color-default-hover)"
|
||||||
|
: undefined
|
||||||
<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))}
|
|
||||||
>
|
>
|
||||||
<Stack gap="xs">
|
File Contents
|
||||||
{packages.map((pkg, index) => (
|
</Accordion.Control>
|
||||||
<Radio
|
<Accordion.Panel>
|
||||||
key={index}
|
<Textarea
|
||||||
value={String(index)}
|
value={draft.rawText}
|
||||||
label={formatPackageLabel(pkg)}
|
onChange={(e) => onJsonChange(e.currentTarget.value)}
|
||||||
/>
|
ff="monospace"
|
||||||
))}
|
autosize
|
||||||
</Stack>
|
minRows={8}
|
||||||
</Radio.Group>
|
maxRows={15}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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"
|
rightSectionPointerEvents="auto"
|
||||||
rightSection={
|
rightSection={
|
||||||
envVar.value ? (
|
draft.rawText ? (
|
||||||
<ClearButton
|
<ClearButton onClick={() => onJsonChange("")} />
|
||||||
onClick={() => onEnvVarChange(envVar.name, "")}
|
|
||||||
/>
|
|
||||||
) : null
|
) : 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">
|
<Group justify="flex-end">
|
||||||
<Button variant="light" onClick={onValidate}>
|
<Button onClick={onAddServer} disabled={addDisabled}>
|
||||||
Validate Again
|
Add Server
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="light" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onAddServer}>Add Server</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ describe("ServerAddMenu", () => {
|
||||||
expect(onAddManually).toHaveBeenCalledTimes(1);
|
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 user = userEvent.setup();
|
||||||
const onImportConfig = vi.fn();
|
const onImportConfig = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
|
|
@ -48,14 +48,14 @@ describe("ServerAddMenu", () => {
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
|
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
|
||||||
const item = screen.getByRole("menuitem", {
|
const item = screen.getByRole("menuitem", {
|
||||||
name: /Import config/,
|
name: /Import from client config/,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
await user.click(item);
|
await user.click(item);
|
||||||
expect(onImportConfig).toHaveBeenCalledTimes(1);
|
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 user = userEvent.setup();
|
||||||
const onImportServerJson = vi.fn();
|
const onImportServerJson = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
|
|
@ -67,7 +67,7 @@ describe("ServerAddMenu", () => {
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
|
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
|
||||||
const item = screen.getByRole("menuitem", {
|
const item = screen.getByRole("menuitem", {
|
||||||
name: /Import server\.json/,
|
name: /Import from registry config/,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
await user.click(item);
|
await user.click(item);
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,12 @@ export function ServerAddMenu({
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={onAddManually}>+ Add manually</Menu.Item>
|
<Menu.Item onClick={onAddManually}>+ Add manually</Menu.Item>
|
||||||
<Menu.Item onClick={onImportConfig}>Import config</Menu.Item>
|
<Menu.Item onClick={onImportConfig}>
|
||||||
<Menu.Item onClick={onImportServerJson}>Import server.json</Menu.Item>
|
Import from client config
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item onClick={onImportServerJson}>
|
||||||
|
Import from registry config
|
||||||
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -277,4 +277,74 @@ describe("ServerCard", () => {
|
||||||
).toBeInTheDocument();
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { BorderAnimate } from "@gfazioli/mantine-border-animate";
|
||||||
import type {
|
import type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ServerEntry,
|
ServerEntry,
|
||||||
|
|
@ -33,6 +34,21 @@ export interface ServerCardProps extends ServerEntry {
|
||||||
* drag-and-drop.
|
* drag-and-drop.
|
||||||
*/
|
*/
|
||||||
dragHandle?: ReactNode;
|
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({
|
const HeaderLeft = Group.withProps({
|
||||||
|
|
@ -120,19 +136,37 @@ export function ServerCard({
|
||||||
compact = false,
|
compact = false,
|
||||||
writable = true,
|
writable = true,
|
||||||
dragHandle,
|
dragHandle,
|
||||||
|
highlighted = false,
|
||||||
|
scrollOnHighlight = true,
|
||||||
|
onClearHighlight,
|
||||||
}: ServerCardProps) {
|
}: ServerCardProps) {
|
||||||
const isDimmed = activeServer !== undefined && activeServer !== id;
|
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 transport = getTransport(config);
|
||||||
const commandOrUrl = getCommandOrUrl(config);
|
const commandOrUrl = getCommandOrUrl(config);
|
||||||
const version = info?.version;
|
const version = info?.version;
|
||||||
const protocolVersion =
|
const protocolVersion =
|
||||||
connection.status === "connected" ? connection.protocolVersion : undefined;
|
connection.status === "connected" ? connection.protocolVersion : undefined;
|
||||||
|
|
||||||
return (
|
const card = (
|
||||||
<Card
|
<Card
|
||||||
|
ref={rootRef}
|
||||||
withBorder
|
withBorder
|
||||||
padding="lg"
|
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}
|
variant={isDimmed ? "disabled" : undefined}
|
||||||
|
onClick={highlighted ? onClearHighlight : undefined}
|
||||||
{...(isDimmed ? { "aria-disabled": true, inert: true } : {})}
|
{...(isDimmed ? { "aria-disabled": true, inert: true } : {})}
|
||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
|
@ -202,4 +236,19 @@ export function ServerCard({
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ interface FormState {
|
||||||
const SectionStack = Stack.withProps({ gap: "md" });
|
const SectionStack = Stack.withProps({ gap: "md" });
|
||||||
const FieldGrid = Stack.withProps({ gap: "sm" });
|
const FieldGrid = Stack.withProps({ gap: "sm" });
|
||||||
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
|
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> = {
|
const MODE_TITLES: Record<ServerConfigModalMode, string> = {
|
||||||
add: "Add server",
|
add: "Add server",
|
||||||
|
|
@ -255,7 +256,7 @@ export function ServerConfigModal({
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="lg"
|
size="lg"
|
||||||
centered
|
centered
|
||||||
title={MODE_TITLES[mode]}
|
title={<ModalTitle>{MODE_TITLES[mode]}</ModalTitle>}
|
||||||
>
|
>
|
||||||
<SectionStack>
|
<SectionStack>
|
||||||
<FieldGrid>
|
<FieldGrid>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -135,4 +135,51 @@ describe("ServerListScreen", () => {
|
||||||
expect(screen.queryByText("Read-only session")).not.toBeInTheDocument();
|
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
Loading…
Reference in a new issue