feat(web,core): import client configs and registry server.json (#1348) (#1511)

* 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:
Cliff Hall 2026-06-23 19:52:08 -04:00 • committed by GitHub
commit 97b1a7fbff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 4238 additions and 176 deletions

View file

@ -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();
},
};

View file

@ -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();
});
});

View file

@ -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>
);
}