From 97b1a7fbff15ad446a2d252134d679a99a5ef79a Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 23 Jun 2026 19:52:08 -0400 Subject: [PATCH] feat(web,core): import client configs and registry server.json (#1348) (#1511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * feat(web): collapse the server.json textarea into a File Contents disclosure Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * 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) * 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) * 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) * feat(web): rename "Import client config" to "Import from client config" Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * 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) * 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) * 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 ; 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .gitignore | 6 +- AGENTS.md | 5 + clients/web/package-lock.json | 42 ++ clients/web/package.json | 1 + clients/web/src/App.tsx | 82 +++- .../ImportServerJsonPanel.stories.tsx | 2 - .../ImportServerJsonPanel.test.tsx | 157 +++++-- .../ImportServerJsonPanel.tsx | 274 +++++++---- .../ServerAddMenu/ServerAddMenu.test.tsx | 8 +- .../groups/ServerAddMenu/ServerAddMenu.tsx | 8 +- .../groups/ServerCard/ServerCard.test.tsx | 70 +++ .../groups/ServerCard/ServerCard.tsx | 53 ++- .../ServerConfigModal/ServerConfigModal.tsx | 3 +- .../ServerImportConfigModal.stories.tsx | 83 ++++ .../ServerImportConfigModal.test.tsx | 380 +++++++++++++++ .../ServerImportConfigModal.tsx | 281 ++++++++++++ .../ServerImportJsonModal.stories.tsx | 53 +++ .../ServerImportJsonModal.test.tsx | 341 ++++++++++++++ .../ServerImportJsonModal.tsx | 61 +++ .../ServerListScreen.test.tsx | 47 ++ .../ServerListScreen/ServerListScreen.tsx | 19 + .../views/InspectorView/InspectorView.tsx | 8 + .../web/src/hooks/useImportClientConfig.ts | 321 +++++++++++++ clients/web/src/hooks/useServerJsonImport.ts | 308 +++++++++++++ clients/web/src/main.tsx | 1 + .../src/test/core/react/useServers.test.tsx | 23 + .../mcp/import/clientConfig.test.ts | 119 +++++ .../test/integration/mcp/import/merge.test.ts | 51 +++ .../mcp/import/resolveSource.test.ts | 64 +++ .../integration/mcp/import/serverJson.test.ts | 433 ++++++++++++++++++ .../integration/mcp/import/strategies.test.ts | 84 ++++ .../mcp/remote/servers-route.test.ts | 30 ++ .../test/integration/storage/store-id.test.ts | 21 + core/mcp/import/clientConfig.ts | 105 +++++ core/mcp/import/index.ts | 40 ++ core/mcp/import/merge.ts | 64 +++ core/mcp/import/resolveSource.ts | 57 +++ core/mcp/import/serverJson.ts | 420 +++++++++++++++++ core/mcp/import/strategies.ts | 116 +++++ core/mcp/import/types.ts | 60 +++ core/mcp/remote/node/server.ts | 64 ++- core/react/useServers.ts | 23 + core/storage/store-id.ts | 15 + core/storage/store-io.ts | 13 +- 44 files changed, 4239 insertions(+), 177 deletions(-) create mode 100644 clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.stories.tsx create mode 100644 clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.test.tsx create mode 100644 clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx create mode 100644 clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.stories.tsx create mode 100644 clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.test.tsx create mode 100644 clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.tsx create mode 100644 clients/web/src/hooks/useImportClientConfig.ts create mode 100644 clients/web/src/hooks/useServerJsonImport.ts create mode 100644 clients/web/src/test/integration/mcp/import/clientConfig.test.ts create mode 100644 clients/web/src/test/integration/mcp/import/merge.test.ts create mode 100644 clients/web/src/test/integration/mcp/import/resolveSource.test.ts create mode 100644 clients/web/src/test/integration/mcp/import/serverJson.test.ts create mode 100644 clients/web/src/test/integration/mcp/import/strategies.test.ts create mode 100644 clients/web/src/test/integration/storage/store-id.test.ts create mode 100644 core/mcp/import/clientConfig.ts create mode 100644 core/mcp/import/index.ts create mode 100644 core/mcp/import/merge.ts create mode 100644 core/mcp/import/resolveSource.ts create mode 100644 core/mcp/import/serverJson.ts create mode 100644 core/mcp/import/strategies.ts create mode 100644 core/mcp/import/types.ts create mode 100644 core/storage/store-id.ts diff --git a/.gitignore b/.gitignore index e9e5db73..1e686966 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 4f7bb68b..4a0201cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index a14dc7d5..e6de271a 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -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", diff --git a/clients/web/package.json b/clients/web/package.json index 9c3e075d..791ad1c6 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -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", diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 544cd5e4..9bdf0b18 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -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( + [], + ); + 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} /> + setImportConfigOpen(false)} + onFetchSource={importSource} + onAddServer={addServerHighlighted} + onUpdateServer={updateServer} + /> + setImportJsonOpen(false)} + onAddServer={addServerHighlighted} + /> = { component: ImportServerJsonPanel, args: { onJsonChange: fn(), - onValidate: fn(), onSelectPackage: fn(), onEnvVarChange: fn(), onServerNameChange: fn(), onAddServer: fn(), - onCancel: fn(), envVars: [], validation: [], }, diff --git a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx index 4fdb9ffd..f060547c 100644 --- a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx +++ b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx @@ -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( { 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( + , + ); + expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled(); + }); + + it("renders the File Contents control with the highlight background", () => { + renderWithMantine( + , + ); + 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( , ); - 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( , @@ -117,7 +139,7 @@ describe("ImportServerJsonPanel", () => { renderWithMantine( { { , @@ -186,12 +208,12 @@ describe("ImportServerJsonPanel", () => { , ); - 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( + , + ); + 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( + , + ); + 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( , ); expect(screen.getByDisplayValue("Custom Name")).toBeInTheDocument(); }); + + it("shows the derived name in a read-only From configuration field", () => { + renderWithMantine( + , + ); + 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( + , + ); + // 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( + , + ); + expect(screen.getByText("Validation Results")).toBeInTheDocument(); + expect(screen.getByLabelText("Override")).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.tsx b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.tsx index 49a32794..bd3f9a20 100644 --- a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.tsx +++ b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.tsx @@ -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 ( - Import MCP Registry server.json + + Paste server.json content, or load it from a file: + {onPickFile ? ( + + {(props) => ( + + )} + + ) : null} + - Paste server.json content or drag and drop a file: - -