* 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/
|
||||
|
||||
# Local debugging artifacts dropped at the repo root (e.g. attached to
|
||||
# Claude Code conversations): screenshots and exported Inspector history
|
||||
# dumps. Should never be part of the source tree.
|
||||
# Claude Code conversations): screenshots, exported Inspector history dumps,
|
||||
# and ad-hoc registry/client config fixtures used for manual import testing.
|
||||
# Should never be part of the source tree.
|
||||
/*.png
|
||||
/inspector-history-*.json
|
||||
/*.server.json
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ inspector/
|
|||
│ ├── json/ # JSON utilities and parameter/argument conversion
|
||||
│ ├── logging/ # Silent pino logger singleton
|
||||
│ ├── mcp/ # InspectorClient runtime + state stores
|
||||
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
|
||||
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
|
||||
│ │ │ # server.json parser, strategy registry + well-known
|
||||
│ │ │ # paths, strategy-agnostic merge. Pure/isomorphic;
|
||||
│ │ │ # used by the web file-upload path + /api/import-source.
|
||||
│ │ ├── node/ # Node stdio transport factory
|
||||
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
||||
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
||||
|
|
|
|||
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/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@gfazioli/mantine-border-animate": "^2.0.3",
|
||||
"@hono/node-server": "^1.19.14",
|
||||
"@mantine/core": "^8.3.17",
|
||||
"@mantine/form": "^8.3.17",
|
||||
|
|
@ -1241,6 +1242,19 @@
|
|||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@gfazioli/mantine-border-animate": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@gfazioli/mantine-border-animate/-/mantine-border-animate-2.0.3.tgz",
|
||||
"integrity": "sha512-6JmO4Cox2KAjIXWWNIewwuudTtTGS5QrcWqjgch8Kn1Zu2bwhZAk4n1y2z7YNJsnfZGVM3XcxLbdGivJcCiNMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@mantine/core": ">=7.0.0",
|
||||
"@mantine/hooks": ">=7.0.0",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
|
|
@ -2675,6 +2689,34 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
|
||||
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
|
||||
"integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.44.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@gfazioli/mantine-border-animate": "^2.0.3",
|
||||
"@hono/node-server": "^1.19.14",
|
||||
"@mantine/core": "^8.3.17",
|
||||
"@mantine/form": "^8.3.17",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ import {
|
|||
type ServerConfigModalMode,
|
||||
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
||||
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
|
||||
import { ServerImportConfigModal } from "./components/groups/ServerImportConfigModal/ServerImportConfigModal";
|
||||
import { ServerImportJsonModal } from "./components/groups/ServerImportJsonModal/ServerImportJsonModal";
|
||||
import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
|
||||
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
|
||||
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
|
||||
|
|
@ -489,6 +491,7 @@ function App() {
|
|||
updateServerSettings,
|
||||
removeServer,
|
||||
reorderServers,
|
||||
importSource,
|
||||
} = useServers({
|
||||
baseUrl:
|
||||
typeof window !== "undefined"
|
||||
|
|
@ -503,6 +506,23 @@ function App() {
|
|||
mode: ServerConfigModalMode;
|
||||
targetId?: string;
|
||||
} | null>(null);
|
||||
// Import-flow modals (#1348): "Import from client config" (other-client
|
||||
// config merge) and "Import from registry config" (registry single-server
|
||||
// import).
|
||||
const [importConfigOpen, setImportConfigOpen] = useState(false);
|
||||
const [importJsonOpen, setImportJsonOpen] = useState(false);
|
||||
// Ids of freshly-added servers (manual or import) — their cards draw an
|
||||
// animated border (and the first scrolls into view) until clicked. A batch
|
||||
// import accumulates all of its ids here; opening an add/import modal starts a
|
||||
// fresh batch. (#1348)
|
||||
const [highlightedServerIds, setHighlightedServerIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const clearHighlight = useCallback(
|
||||
(id: string) =>
|
||||
setHighlightedServerIds((ids) => ids.filter((x) => x !== id)),
|
||||
[],
|
||||
);
|
||||
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
|
@ -2083,14 +2103,6 @@ function App() {
|
|||
);
|
||||
}, [logs, activeServerId]);
|
||||
|
||||
// Action stubs — these UI affordances exist but require additional
|
||||
// wiring (server CRUD, history pinning, app sandbox round-trip, log
|
||||
// export). Tracked separately; the noop keeps the prop interface
|
||||
// satisfied without lying about behavior.
|
||||
const todoNoop = useCallback(() => {
|
||||
/* TODO: not wired yet */
|
||||
}, []);
|
||||
|
||||
// Download the current server list as a canonical mcp.json file. Uses the
|
||||
// in-memory `servers` list (kept in sync with disk by useServers' refresh-
|
||||
// after-mutate flow) so there's no extra HTTP roundtrip. Serialization
|
||||
|
|
@ -2156,6 +2168,20 @@ function App() {
|
|||
|
||||
// Submit handler for the Add / Edit / Clone modal. Add and Clone both go
|
||||
// through addServer; Edit uses updateServer (which supports id rename).
|
||||
// Add a server, then mark it as the freshly-added one so the list scrolls to
|
||||
// it and highlights it. Used by manual add/clone and both import flows; edits
|
||||
// and conflict-overwrites (updateServer) intentionally don't highlight.
|
||||
// Accumulates into the current highlight batch (a multi-server import adds
|
||||
// each id), deduped. The batch is reset to empty when an add/import modal
|
||||
// opens (see the menu handlers).
|
||||
const addServerHighlighted = useCallback(
|
||||
async (id: string, config: MCPServerConfig) => {
|
||||
await addServer(id, config);
|
||||
setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id]));
|
||||
},
|
||||
[addServer],
|
||||
);
|
||||
|
||||
// On rename of the active server, keep activeServerId pointed at the new id.
|
||||
const onConfigSubmit = useCallback(
|
||||
async (id: string, config: MCPServerConfig) => {
|
||||
|
|
@ -2168,9 +2194,9 @@ function App() {
|
|||
return;
|
||||
}
|
||||
// add or clone
|
||||
await addServer(id, config);
|
||||
await addServerHighlighted(id, config);
|
||||
},
|
||||
[configModal, addServer, updateServer, activeServerId],
|
||||
[configModal, addServerHighlighted, updateServer, activeServerId],
|
||||
);
|
||||
|
||||
// Derive the existingIds list the modal uses for uniqueness validation.
|
||||
|
|
@ -2399,14 +2425,26 @@ function App() {
|
|||
onDisconnect={() => {
|
||||
void onDisconnect();
|
||||
}}
|
||||
onServerAdd={() => setConfigModal({ mode: "add" })}
|
||||
onServerImportConfig={todoNoop}
|
||||
onServerImportJson={todoNoop}
|
||||
onServerAdd={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "add" });
|
||||
}}
|
||||
onServerImportConfig={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportConfigOpen(true);
|
||||
}}
|
||||
onServerImportJson={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportJsonOpen(true);
|
||||
}}
|
||||
onServerExport={onServerExport}
|
||||
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
|
||||
onServerSettings={(id) => setSettingsModalTargetId(id)}
|
||||
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
|
||||
onServerClone={(id) => setConfigModal({ mode: "clone", targetId: id })}
|
||||
onServerClone={(id) => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "clone", targetId: id });
|
||||
}}
|
||||
onServerRemove={(id) => {
|
||||
const target = servers.find((s) => s.id === id);
|
||||
if (target) setRemoveTarget(target);
|
||||
|
|
@ -2425,6 +2463,8 @@ function App() {
|
|||
});
|
||||
});
|
||||
}}
|
||||
highlightedServerIds={highlightedServerIds}
|
||||
onClearHighlight={clearHighlight}
|
||||
serverSupportsTaskToolCalls={
|
||||
!!capabilities?.tasks?.requests?.tools?.call
|
||||
}
|
||||
|
|
@ -2490,6 +2530,20 @@ function App() {
|
|||
onClose={() => setConfigModal(null)}
|
||||
onSubmit={onConfigSubmit}
|
||||
/>
|
||||
<ServerImportConfigModal
|
||||
opened={importConfigOpen}
|
||||
existingIds={existingIds}
|
||||
onClose={() => setImportConfigOpen(false)}
|
||||
onFetchSource={importSource}
|
||||
onAddServer={addServerHighlighted}
|
||||
onUpdateServer={updateServer}
|
||||
/>
|
||||
<ServerImportJsonModal
|
||||
opened={importJsonOpen}
|
||||
existingIds={existingIds}
|
||||
onClose={() => setImportJsonOpen(false)}
|
||||
onAddServer={addServerHighlighted}
|
||||
/>
|
||||
<ServerSettingsModal
|
||||
// Remount per open (and per target server) so the accordion resets to
|
||||
// its initial "options" section — the body-dropped toast deep-links
|
||||
|
|
|
|||
|
|
@ -13,12 +13,10 @@ const meta: Meta<typeof ImportServerJsonPanel> = {
|
|||
component: ImportServerJsonPanel,
|
||||
args: {
|
||||
onJsonChange: fn(),
|
||||
onValidate: fn(),
|
||||
onSelectPackage: fn(),
|
||||
onEnvVarChange: fn(),
|
||||
onServerNameChange: fn(),
|
||||
onAddServer: fn(),
|
||||
onCancel: fn(),
|
||||
envVars: [],
|
||||
validation: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,18 +14,24 @@ const emptyDraft: InspectorServerJsonDraft = {
|
|||
envOverrides: {},
|
||||
};
|
||||
|
||||
// Validation results, package selection, env vars, and the name override are
|
||||
// only shown once content has been pasted/loaded, so tests for those sections
|
||||
// use a draft with non-empty rawText.
|
||||
const draftWithContent: InspectorServerJsonDraft = {
|
||||
rawText: '{"name":"x"}',
|
||||
envOverrides: {},
|
||||
};
|
||||
|
||||
const baseHandlers = {
|
||||
onJsonChange: vi.fn(),
|
||||
onValidate: vi.fn(),
|
||||
onSelectPackage: vi.fn(),
|
||||
onEnvVarChange: vi.fn(),
|
||||
onServerNameChange: vi.fn(),
|
||||
onAddServer: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ImportServerJsonPanel", () => {
|
||||
it("renders the title and the action buttons", () => {
|
||||
it("renders the Add Server action", () => {
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
|
|
@ -34,16 +40,12 @@ describe("ImportServerJsonPanel", () => {
|
|||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Import MCP Registry server.json"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Validate Again" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Server" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Cancel" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onJsonChange when typing in the textarea", async () => {
|
||||
|
|
@ -65,27 +67,47 @@ describe("ImportServerJsonPanel", () => {
|
|||
expect(onJsonChange).toHaveBeenCalledWith("x");
|
||||
});
|
||||
|
||||
it("invokes onValidate, onCancel, and onAddServer when their buttons are clicked", async () => {
|
||||
it("disables the Add Server button when addDisabled is set", () => {
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
addDisabled
|
||||
draft={emptyDraft}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders the File Contents control with the highlight background", () => {
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
fileContentsHighlight
|
||||
draft={emptyDraft}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "File Contents" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onAddServer when the Add Server button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValidate = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const onAddServer = vi.fn();
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
onValidate={onValidate}
|
||||
onCancel={onCancel}
|
||||
onAddServer={onAddServer}
|
||||
draft={emptyDraft}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Validate Again" }));
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Server" }));
|
||||
expect(onValidate).toHaveBeenCalledTimes(1);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onAddServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
@ -99,7 +121,7 @@ describe("ImportServerJsonPanel", () => {
|
|||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
draft={emptyDraft}
|
||||
draft={draftWithContent}
|
||||
validation={validation}
|
||||
envVars={[]}
|
||||
/>,
|
||||
|
|
@ -117,7 +139,7 @@ describe("ImportServerJsonPanel", () => {
|
|||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
draft={emptyDraft}
|
||||
draft={draftWithContent}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
packages={packages}
|
||||
|
|
@ -137,7 +159,7 @@ describe("ImportServerJsonPanel", () => {
|
|||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
onSelectPackage={onSelectPackage}
|
||||
draft={emptyDraft}
|
||||
draft={draftWithContent}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
packages={packages}
|
||||
|
|
@ -167,7 +189,7 @@ describe("ImportServerJsonPanel", () => {
|
|||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
onEnvVarChange={onEnvVarChange}
|
||||
draft={emptyDraft}
|
||||
draft={draftWithContent}
|
||||
validation={[]}
|
||||
envVars={envVars}
|
||||
/>,
|
||||
|
|
@ -186,12 +208,12 @@ describe("ImportServerJsonPanel", () => {
|
|||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
onServerNameChange={onServerNameChange}
|
||||
draft={emptyDraft}
|
||||
draft={draftWithContent}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
const nameInput = screen.getByLabelText(/Server Name/);
|
||||
const nameInput = screen.getByLabelText("Override");
|
||||
await user.type(nameInput, "X");
|
||||
expect(onServerNameChange).toHaveBeenCalledWith("X");
|
||||
});
|
||||
|
|
@ -230,15 +252,98 @@ describe("ImportServerJsonPanel", () => {
|
|||
expect(onServerNameChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("does not render the file picker when onPickFile is absent", () => {
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
draft={emptyDraft}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Choose file/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a file picker and invokes onPickFile on upload", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPickFile = vi.fn();
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
onPickFile={onPickFile}
|
||||
draft={emptyDraft}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Choose file/ }),
|
||||
).toBeInTheDocument();
|
||||
const file = new File(["{}"], "server.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const input = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(input, file);
|
||||
expect(onPickFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the existing nameOverride value", () => {
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
draft={{ ...emptyDraft, nameOverride: "Custom Name" }}
|
||||
draft={{ ...draftWithContent, nameOverride: "Custom Name" }}
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByDisplayValue("Custom Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the derived name in a read-only From configuration field", () => {
|
||||
renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
draft={draftWithContent}
|
||||
defaultServerName="weather"
|
||||
validation={[]}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
const fromConfig = screen.getByLabelText("From configuration");
|
||||
expect(fromConfig).toHaveValue("weather");
|
||||
expect(fromConfig).toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
it("hides validation results and the name override until content is present", () => {
|
||||
const validation: ValidationResult[] = [
|
||||
{ type: "info", message: "Paste server.json content to validate." },
|
||||
];
|
||||
const { rerender } = renderWithMantine(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
draft={emptyDraft}
|
||||
validation={validation}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
// Empty draft: gated sections hidden.
|
||||
expect(screen.queryByText("Validation Results")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Override")).not.toBeInTheDocument();
|
||||
|
||||
// Once content is present, they appear.
|
||||
rerender(
|
||||
<ImportServerJsonPanel
|
||||
{...baseHandlers}
|
||||
draft={draftWithContent}
|
||||
validation={validation}
|
||||
envVars={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Validation Results")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Override")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Radio,
|
||||
Stack,
|
||||
|
|
@ -36,12 +38,34 @@ export interface ImportServerJsonPanelProps {
|
|||
packages?: PackageInfo[];
|
||||
envVars: EnvVarInfo[];
|
||||
onJsonChange: (content: string) => void;
|
||||
onValidate: () => void;
|
||||
onSelectPackage: (index: number) => void;
|
||||
onEnvVarChange: (name: string, value: string) => void;
|
||||
onServerNameChange: (name: string) => void;
|
||||
/** The id derived from the server.json `name`, shown read-only in the
|
||||
* Server Name disclosure (the override field replaces it when set). */
|
||||
defaultServerName?: string;
|
||||
onAddServer: () => void;
|
||||
onCancel: () => void;
|
||||
/** Disables the Add Server button while the pasted content isn't valid. */
|
||||
addDisabled?: boolean;
|
||||
/**
|
||||
* Controls the "File Contents" disclosure. When `onFileContentsChange` is
|
||||
* provided the disclosure is controlled (the wiring layer can auto-collapse
|
||||
* it after content loads); otherwise it's uncontrolled and defaults to open.
|
||||
*/
|
||||
fileContentsOpen?: boolean;
|
||||
onFileContentsChange?: (open: boolean) => void;
|
||||
/**
|
||||
* When true, the "File Contents" control is painted with its hover
|
||||
* background — used as a brief pre-collapse flash so the auto-collapse reads
|
||||
* intentionally.
|
||||
*/
|
||||
fileContentsHighlight?: boolean;
|
||||
/**
|
||||
* Load server.json content from a file. When provided, a "Choose file…"
|
||||
* button is rendered next to the paste hint; the handler reads the file and
|
||||
* feeds its text back through `onJsonChange`.
|
||||
*/
|
||||
onPickFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
const validationIcons: Record<
|
||||
|
|
@ -69,118 +93,178 @@ export function ImportServerJsonPanel({
|
|||
packages,
|
||||
envVars,
|
||||
onJsonChange,
|
||||
onValidate,
|
||||
onSelectPackage,
|
||||
onEnvVarChange,
|
||||
onServerNameChange,
|
||||
defaultServerName,
|
||||
onAddServer,
|
||||
onCancel,
|
||||
addDisabled,
|
||||
fileContentsOpen,
|
||||
onFileContentsChange,
|
||||
fileContentsHighlight,
|
||||
onPickFile,
|
||||
}: ImportServerJsonPanelProps) {
|
||||
// Validation results + the name-override field only make sense once there's
|
||||
// something to validate, so they stay hidden until content is pasted/loaded.
|
||||
const hasContent = draft.rawText.trim().length > 0;
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Title order={4}>Import MCP Registry server.json</Title>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<HintText>Paste server.json content, or load it from a file:</HintText>
|
||||
{onPickFile ? (
|
||||
<FileButton accept="application/json,.json" onChange={onPickFile}>
|
||||
{(props) => (
|
||||
<Button {...props} variant="default" size="xs">
|
||||
Choose file…
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<HintText>Paste server.json content or drag and drop a file:</HintText>
|
||||
|
||||
<Textarea
|
||||
value={draft.rawText}
|
||||
onChange={(e) => onJsonChange(e.currentTarget.value)}
|
||||
ff="monospace"
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={15}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
draft.rawText ? (
|
||||
<ClearButton onClick={() => onJsonChange("")} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title order={5}>Validation Results:</Title>
|
||||
|
||||
{validation.map((result, index) => {
|
||||
const { icon, color } = validationIcons[result.type];
|
||||
return (
|
||||
<Group key={index} gap="xs">
|
||||
<Text c={color}>{icon}</Text>
|
||||
<Text size="sm">{result.message}</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
|
||||
{packages && packages.length > 1 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Title order={5}>Package Selection:</Title>
|
||||
<Radio.Group
|
||||
value={String(draft.selectedPackageIndex ?? 0)}
|
||||
onChange={(value) => onSelectPackage(Number(value))}
|
||||
<Accordion
|
||||
variant="separated"
|
||||
transitionDuration={325}
|
||||
{...(onFileContentsChange
|
||||
? {
|
||||
value: fileContentsOpen ? "file-contents" : null,
|
||||
onChange: (value: string | null) =>
|
||||
onFileContentsChange(value === "file-contents"),
|
||||
}
|
||||
: { defaultValue: "file-contents" })}
|
||||
>
|
||||
<Accordion.Item value="file-contents">
|
||||
<Accordion.Control
|
||||
bg={
|
||||
fileContentsHighlight
|
||||
? "var(--mantine-color-default-hover)"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{packages.map((pkg, index) => (
|
||||
<Radio
|
||||
key={index}
|
||||
value={String(index)}
|
||||
label={formatPackageLabel(pkg)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{envVars.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Title order={5}>Environment Variables:</Title>
|
||||
{envVars.map((envVar) => (
|
||||
<TextInput
|
||||
key={envVar.name}
|
||||
label={envVar.name}
|
||||
description={envVar.description}
|
||||
withAsterisk={envVar.required}
|
||||
value={envVar.value}
|
||||
onChange={(e) =>
|
||||
onEnvVarChange(envVar.name, e.currentTarget.value)
|
||||
}
|
||||
File Contents
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Textarea
|
||||
value={draft.rawText}
|
||||
onChange={(e) => onJsonChange(e.currentTarget.value)}
|
||||
ff="monospace"
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={15}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
envVar.value ? (
|
||||
<ClearButton
|
||||
onClick={() => onEnvVarChange(envVar.name, "")}
|
||||
/>
|
||||
draft.rawText ? (
|
||||
<ClearButton onClick={() => onJsonChange("")} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{hasContent && (
|
||||
<>
|
||||
<Accordion variant="separated" defaultValue="validation">
|
||||
<Accordion.Item value="validation">
|
||||
<Accordion.Control>Validation Results</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{validation.map((result, index) => {
|
||||
const { icon, color } = validationIcons[result.type];
|
||||
return (
|
||||
<Group key={index} gap="xs">
|
||||
<Text c={color}>{icon}</Text>
|
||||
<Text size="sm">{result.message}</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{packages && packages.length > 1 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Title order={5}>Package Selection:</Title>
|
||||
<Radio.Group
|
||||
value={String(draft.selectedPackageIndex ?? 0)}
|
||||
onChange={(value) => onSelectPackage(Number(value))}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{packages.map((pkg, index) => (
|
||||
<Radio
|
||||
key={index}
|
||||
value={String(index)}
|
||||
label={formatPackageLabel(pkg)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{envVars.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Title order={5}>Environment Variables:</Title>
|
||||
{envVars.map((envVar) => (
|
||||
<TextInput
|
||||
key={envVar.name}
|
||||
label={envVar.name}
|
||||
description={envVar.description}
|
||||
withAsterisk={envVar.required}
|
||||
value={envVar.value}
|
||||
onChange={(e) =>
|
||||
onEnvVarChange(envVar.name, e.currentTarget.value)
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
envVar.value ? (
|
||||
<ClearButton
|
||||
onClick={() => onEnvVarChange(envVar.name, "")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Accordion variant="separated" defaultValue="server-name">
|
||||
<Accordion.Item value="server-name">
|
||||
<Accordion.Control>Server Name</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="From configuration"
|
||||
description="The id derived from the server.json name."
|
||||
value={defaultServerName ?? ""}
|
||||
readOnly
|
||||
/>
|
||||
<TextInput
|
||||
label="Override"
|
||||
description="Optional. Used instead of the name above."
|
||||
value={draft.nameOverride ?? ""}
|
||||
onChange={(e) => onServerNameChange(e.currentTarget.value)}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
draft.nameOverride ? (
|
||||
<ClearButton onClick={() => onServerNameChange("")} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<TextInput
|
||||
label="Server Name (optional override)"
|
||||
value={draft.nameOverride ?? ""}
|
||||
onChange={(e) => onServerNameChange(e.currentTarget.value)}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
draft.nameOverride ? (
|
||||
<ClearButton onClick={() => onServerNameChange("")} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={onValidate}>
|
||||
Validate Again
|
||||
<Button onClick={onAddServer} disabled={addDisabled}>
|
||||
Add Server
|
||||
</Button>
|
||||
<Button variant="light" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAddServer}>Add Server</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe("ServerAddMenu", () => {
|
|||
expect(onAddManually).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onImportConfig when 'Import config' is clicked", async () => {
|
||||
it("invokes onImportConfig when 'Import from client config' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onImportConfig = vi.fn();
|
||||
renderWithMantine(
|
||||
|
|
@ -48,14 +48,14 @@ describe("ServerAddMenu", () => {
|
|||
);
|
||||
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
|
||||
const item = screen.getByRole("menuitem", {
|
||||
name: /Import config/,
|
||||
name: /Import from client config/,
|
||||
hidden: true,
|
||||
});
|
||||
await user.click(item);
|
||||
expect(onImportConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onImportServerJson when 'Import server.json' is clicked", async () => {
|
||||
it("invokes onImportServerJson when 'Import from registry config' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onImportServerJson = vi.fn();
|
||||
renderWithMantine(
|
||||
|
|
@ -67,7 +67,7 @@ describe("ServerAddMenu", () => {
|
|||
);
|
||||
await user.click(screen.getByRole("button", { name: /Add Servers/ }));
|
||||
const item = screen.getByRole("menuitem", {
|
||||
name: /Import server\.json/,
|
||||
name: /Import from registry config/,
|
||||
hidden: true,
|
||||
});
|
||||
await user.click(item);
|
||||
|
|
|
|||
|
|
@ -20,8 +20,12 @@ export function ServerAddMenu({
|
|||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onAddManually}>+ Add manually</Menu.Item>
|
||||
<Menu.Item onClick={onImportConfig}>Import config</Menu.Item>
|
||||
<Menu.Item onClick={onImportServerJson}>Import server.json</Menu.Item>
|
||||
<Menu.Item onClick={onImportConfig}>
|
||||
Import from client config
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={onImportServerJson}>
|
||||
Import from registry config
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -277,4 +277,74 @@ describe("ServerCard", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("freshly-added highlight", () => {
|
||||
it("scrolls into view when highlighted", () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const orig = Element.prototype.scrollIntoView;
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
try {
|
||||
renderWithMantine(<ServerCard {...baseProps} highlighted />);
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
} finally {
|
||||
Element.prototype.scrollIntoView = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not scroll when not highlighted", () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const orig = Element.prototype.scrollIntoView;
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
try {
|
||||
renderWithMantine(<ServerCard {...baseProps} />);
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Element.prototype.scrollIntoView = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("clears the highlight when the card is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClearHighlight = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerCard
|
||||
{...baseProps}
|
||||
highlighted
|
||||
onClearHighlight={onClearHighlight}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("My MCP Server"));
|
||||
expect(onClearHighlight).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not clear on click when not highlighted", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClearHighlight = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerCard {...baseProps} onClearHighlight={onClearHighlight} />,
|
||||
);
|
||||
await user.click(screen.getByText("My MCP Server"));
|
||||
expect(onClearHighlight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still toggles the connection when the switch is clicked while highlighted", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleConnection = vi.fn();
|
||||
const onClearHighlight = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerCard
|
||||
{...baseProps}
|
||||
connection={disconnected}
|
||||
highlighted
|
||||
onToggleConnection={onToggleConnection}
|
||||
onClearHighlight={onClearHighlight}
|
||||
/>,
|
||||
);
|
||||
// A single click both connects and dismisses the highlight — the card
|
||||
// isn't remounted, so the toggle's action isn't swallowed.
|
||||
await user.click(screen.getByRole("switch"));
|
||||
expect(onToggleConnection).toHaveBeenCalledWith("srv-1");
|
||||
expect(onClearHighlight).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { BorderAnimate } from "@gfazioli/mantine-border-animate";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
ServerEntry,
|
||||
|
|
@ -33,6 +34,21 @@ export interface ServerCardProps extends ServerEntry {
|
|||
* drag-and-drop.
|
||||
*/
|
||||
dragHandle?: ReactNode;
|
||||
/**
|
||||
* When true, the card is freshly added: it draws an animated border (Mantine
|
||||
* Border Animate) to draw the eye. Cleared by `onClearHighlight` on any click
|
||||
* on the card.
|
||||
*/
|
||||
highlighted?: boolean;
|
||||
/**
|
||||
* Whether a highlighted card scrolls itself into view. When several cards are
|
||||
* highlighted at once (a batch import) only the first should scroll, so the
|
||||
* list jumps to the start of the batch rather than fighting over the viewport.
|
||||
* Defaults to true.
|
||||
*/
|
||||
scrollOnHighlight?: boolean;
|
||||
/** Called when a highlighted card is clicked, to dismiss the animated border. */
|
||||
onClearHighlight?: () => void;
|
||||
}
|
||||
|
||||
const HeaderLeft = Group.withProps({
|
||||
|
|
@ -120,19 +136,37 @@ export function ServerCard({
|
|||
compact = false,
|
||||
writable = true,
|
||||
dragHandle,
|
||||
highlighted = false,
|
||||
scrollOnHighlight = true,
|
||||
onClearHighlight,
|
||||
}: ServerCardProps) {
|
||||
const isDimmed = activeServer !== undefined && activeServer !== id;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// When freshly added, bring the card into view (it may be far down a long
|
||||
// list). Only the designated card scrolls (the first of a highlighted batch).
|
||||
useEffect(() => {
|
||||
if (highlighted && scrollOnHighlight) {
|
||||
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [highlighted, scrollOnHighlight]);
|
||||
const transport = getTransport(config);
|
||||
const commandOrUrl = getCommandOrUrl(config);
|
||||
const version = info?.version;
|
||||
const protocolVersion =
|
||||
connection.status === "connected" ? connection.protocolVersion : undefined;
|
||||
|
||||
return (
|
||||
const card = (
|
||||
<Card
|
||||
ref={rootRef}
|
||||
withBorder
|
||||
padding="lg"
|
||||
// BorderAnimate's wrapper is display:flex, so the card must stretch to
|
||||
// fill it — otherwise the card shrinks to content width while the
|
||||
// animated border spans the full grid cell.
|
||||
w="100%"
|
||||
variant={isDimmed ? "disabled" : undefined}
|
||||
onClick={highlighted ? onClearHighlight : undefined}
|
||||
{...(isDimmed ? { "aria-disabled": true, inert: true } : {})}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
|
|
@ -202,4 +236,19 @@ export function ServerCard({
|
|||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Always render the wrapper and toggle `show`/`animate` rather than
|
||||
// conditionally wrapping — swapping the element type on clear would remount
|
||||
// the card (and its connect toggle / buttons), swallowing the click that
|
||||
// triggered the clear.
|
||||
return (
|
||||
<BorderAnimate
|
||||
show={highlighted}
|
||||
animate={highlighted}
|
||||
radius="md"
|
||||
w="100%"
|
||||
>
|
||||
{card}
|
||||
</BorderAnimate>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ interface FormState {
|
|||
const SectionStack = Stack.withProps({ gap: "md" });
|
||||
const FieldGrid = Stack.withProps({ gap: "sm" });
|
||||
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
|
||||
const ModalTitle = Text.withProps({ fw: 700, span: true });
|
||||
|
||||
const MODE_TITLES: Record<ServerConfigModalMode, string> = {
|
||||
add: "Add server",
|
||||
|
|
@ -255,7 +256,7 @@ export function ServerConfigModal({
|
|||
onClose={onClose}
|
||||
size="lg"
|
||||
centered
|
||||
title={MODE_TITLES[mode]}
|
||||
title={<ModalTitle>{MODE_TITLES[mode]}</ModalTitle>}
|
||||
>
|
||||
<SectionStack>
|
||||
<FieldGrid>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
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