From 8427cb993dc647058cbecbc7337264395a22d262 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 23 Jun 2026 10:47:22 -0400 Subject: [PATCH 01/16] 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) --- AGENTS.md | 5 + clients/web/src/App.tsx | 33 +- .../ImportServerJsonPanel.stories.tsx | 1 - .../ImportServerJsonPanel.test.tsx | 67 ++- .../ImportServerJsonPanel.tsx | 35 +- .../ServerConfigModal/ServerConfigModal.tsx | 3 +- .../ServerImportConfigModal.stories.tsx | 83 +++ .../ServerImportConfigModal.test.tsx | 342 +++++++++++++ .../ServerImportConfigModal.tsx | 481 ++++++++++++++++++ .../ServerImportJsonModal.stories.tsx | 53 ++ .../ServerImportJsonModal.test.tsx | 304 +++++++++++ .../ServerImportJsonModal.tsx | 309 +++++++++++ .../src/test/core/react/useServers.test.tsx | 23 + .../mcp/import/clientConfig.test.ts | 94 ++++ .../test/integration/mcp/import/merge.test.ts | 51 ++ .../mcp/import/resolveSource.test.ts | 64 +++ .../integration/mcp/import/serverJson.test.ts | 334 ++++++++++++ .../integration/mcp/import/strategies.test.ts | 84 +++ .../mcp/remote/servers-route.test.ts | 30 ++ core/mcp/import/clientConfig.ts | 87 ++++ core/mcp/import/index.ts | 32 ++ core/mcp/import/merge.ts | 64 +++ core/mcp/import/resolveSource.ts | 57 +++ core/mcp/import/serverJson.ts | 337 ++++++++++++ core/mcp/import/strategies.ts | 116 +++++ core/mcp/import/types.ts | 60 +++ core/mcp/remote/node/server.ts | 64 ++- core/react/useServers.ts | 23 + 28 files changed, 3181 insertions(+), 55 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/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 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 diff --git a/AGENTS.md b/AGENTS.md index 4f7bb68b62..4a0201cfcb 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/src/App.tsx b/clients/web/src/App.tsx index 544cd5e478..8241ab9796 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,10 @@ function App() { mode: ServerConfigModalMode; targetId?: string; } | null>(null); + // Import-flow modals (#1348): "Import config" (other-client config merge) and + // "Import server.json" (registry single-server import). + const [importConfigOpen, setImportConfigOpen] = useState(false); + const [importJsonOpen, setImportJsonOpen] = useState(false); const [settingsModalTargetId, setSettingsModalTargetId] = useState< string | undefined >(undefined); @@ -2083,14 +2090,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 @@ -2400,8 +2399,8 @@ function App() { void onDisconnect(); }} onServerAdd={() => setConfigModal({ mode: "add" })} - onServerImportConfig={todoNoop} - onServerImportJson={todoNoop} + onServerImportConfig={() => setImportConfigOpen(true)} + onServerImportJson={() => setImportJsonOpen(true)} onServerExport={onServerExport} onConnectionInfo={() => setConnectionInfoModalOpen(true)} onServerSettings={(id) => setSettingsModalTargetId(id)} @@ -2490,6 +2489,20 @@ function App() { onClose={() => setConfigModal(null)} onSubmit={onConfigSubmit} /> + setImportConfigOpen(false)} + onFetchSource={importSource} + onAddServer={addServer} + onUpdateServer={updateServer} + /> + setImportJsonOpen(false)} + onAddServer={addServer} + /> = { component: ImportServerJsonPanel, args: { onJsonChange: fn(), - onValidate: fn(), onSelectPackage: fn(), onEnvVarChange: fn(), onServerNameChange: fn(), diff --git a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx index 4fdb9ffd85..b7e4047cb2 100644 --- a/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx +++ b/clients/web/src/components/groups/ImportServerJsonPanel/ImportServerJsonPanel.test.tsx @@ -16,7 +16,6 @@ const emptyDraft: InspectorServerJsonDraft = { const baseHandlers = { onJsonChange: vi.fn(), - onValidate: vi.fn(), onSelectPackage: vi.fn(), onEnvVarChange: vi.fn(), onServerNameChange: vi.fn(), @@ -25,7 +24,7 @@ const baseHandlers = { }; describe("ImportServerJsonPanel", () => { - it("renders the title and the action buttons", () => { + it("renders the action buttons", () => { 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" }), @@ -65,15 +58,26 @@ 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("invokes onCancel and onAddServer when their buttons are clicked", async () => { const user = userEvent.setup(); - const onValidate = vi.fn(); const onCancel = vi.fn(); const onAddServer = vi.fn(); renderWithMantine( { 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); }); @@ -230,6 +232,45 @@ 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( void; - onValidate: () => void; onSelectPackage: (index: number) => void; onEnvVarChange: (name: string, value: string) => void; onServerNameChange: (name: string) => void; onAddServer: () => void; + /** Disables the Add Server button while the pasted content isn't valid. */ + addDisabled?: boolean; onCancel: () => void; + /** + * 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,18 +77,28 @@ export function ImportServerJsonPanel({ packages, envVars, onJsonChange, - onValidate, onSelectPackage, onEnvVarChange, onServerNameChange, onAddServer, + addDisabled, onCancel, + onPickFile, }: ImportServerJsonPanelProps) { return ( - Import MCP Registry server.json - - Paste server.json content or drag and drop a file: + + Paste server.json content, or load it from a file: + {onPickFile ? ( + + {(props) => ( + + )} + + ) : null} +