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
2 changed files with 4238 additions and 176 deletions

View file

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

View file

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