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: - -