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,83 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { useArgs } from "storybook/preview-api";
import type { ImportSourceResult } from "@inspector/core/mcp/import/index.js";
import {
ServerImportConfigModal,
type ServerImportConfigModalProps,
} from "./ServerImportConfigModal";
function InteractiveRender(args: ServerImportConfigModalProps) {
const [, updateArgs] = useArgs<ServerImportConfigModalProps>();
return (
<AppShell>
<AppShell.Main>
<ServerImportConfigModal
{...args}
onClose={() => {
args.onClose();
updateArgs({ opened: false });
}}
/>
</AppShell.Main>
</AppShell>
);
}
const fetchResult: ImportSourceResult = {
type: "cursor",
found: true,
path: "/home/u/.cursor/mcp.json",
searched: ["/home/u/.cursor/mcp.json"],
config: {
mcpServers: {
"new-server": { type: "stdio", command: "npx", args: ["-y", "demo"] },
existing: { type: "streamable-http", url: "https://example.com/mcp" },
},
},
};
const meta: Meta<typeof ServerImportConfigModal> = {
title: "Groups/ServerImportConfigModal",
component: ServerImportConfigModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
existingIds: ["existing"],
onClose: fn(),
onFetchSource: fn(async () => fetchResult),
onAddServer: fn(async () => undefined),
onUpdateServer: fn(async () => undefined),
},
};
export default meta;
type Story = StoryObj<typeof ServerImportConfigModal>;
export const SourcePicker: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByLabelText("Client")).toBeInTheDocument();
await expect(
body.getByRole("button", { name: "Import" }),
).toBeInTheDocument();
await expect(
body.getByRole("button", { name: /From file/ }),
).toBeInTheDocument();
},
};
export const ReviewWithConflict: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.selectOptions(
await body.findByLabelText("Client"),
"Cursor",
);
await userEvent.click(body.getByRole("button", { name: "Import" }));
await expect(await body.findByText("New servers (1)")).toBeInTheDocument();
await expect(body.getByText("Already exists (1)")).toBeInTheDocument();
},
};

View file

@ -0,0 +1,380 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import {
renderWithMantine,
screen,
waitFor,
within,
} from "../../../test/renderWithMantine";
import type { ImportSourceResult } from "@inspector/core/mcp/import/index.js";
import { ServerImportConfigModal } from "./ServerImportConfigModal";
const sourceWithConflict: ImportSourceResult = {
type: "cursor",
found: true,
path: "/home/u/.cursor/mcp.json",
searched: ["/home/u/.cursor/mcp.json"],
config: {
mcpServers: {
alpha: { type: "stdio", command: "a" },
existing: { type: "stdio", command: "e" },
},
},
};
interface Handlers {
onClose: ReturnType<typeof vi.fn>;
onFetchSource: ReturnType<typeof vi.fn>;
onAddServer: ReturnType<typeof vi.fn>;
onUpdateServer: ReturnType<typeof vi.fn>;
}
function setup(
fetchResult: ImportSourceResult | Error = sourceWithConflict,
existingIds: string[] = ["existing"],
): Handlers & { container: HTMLElement } {
const onClose = vi.fn();
const onFetchSource = vi.fn(() =>
fetchResult instanceof Error
? Promise.reject(fetchResult)
: Promise.resolve(fetchResult),
);
const onAddServer = vi.fn().mockResolvedValue(undefined);
const onUpdateServer = vi.fn().mockResolvedValue(undefined);
const { container } = renderWithMantine(
<ServerImportConfigModal
opened
existingIds={existingIds}
onClose={onClose}
onFetchSource={onFetchSource}
onAddServer={onAddServer}
onUpdateServer={onUpdateServer}
/>,
);
return { onClose, onFetchSource, onAddServer, onUpdateServer, container };
}
/** Choose a client in the source dropdown and click Import. */
async function pickClientAndImport(
user: ReturnType<typeof userEvent.setup>,
label: string,
) {
await user.selectOptions(screen.getByLabelText("Client"), label);
await user.click(screen.getByRole("button", { name: "Import" }));
}
describe("ServerImportConfigModal", () => {
it("renders nothing when closed", () => {
renderWithMantine(
<ServerImportConfigModal
opened={false}
existingIds={[]}
onClose={vi.fn()}
onFetchSource={vi.fn()}
onAddServer={vi.fn()}
onUpdateServer={vi.fn()}
/>,
);
expect(
screen.queryByText("Import from client config"),
).not.toBeInTheDocument();
});
it("renders a client dropdown, an Import button, and a file picker", () => {
setup();
// Import is disabled until a client is chosen.
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
expect(
screen.getByRole("button", { name: /From file/ }),
).toBeInTheDocument();
// The dropdown exposes every strategy as an option.
for (const label of ["Claude Desktop", "Cursor", "Cline", "VS Code"]) {
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
}
});
it("enables Import once a client is selected", async () => {
const user = userEvent.setup();
setup();
await user.selectOptions(screen.getByLabelText("Client"), "Cursor");
expect(screen.getByRole("button", { name: "Import" })).toBeEnabled();
});
it("fetches a source and shows additions + conflicts in review", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText("New servers (1)")).toBeInTheDocument(),
);
expect(h.onFetchSource).toHaveBeenCalledWith("cursor");
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.getByText("Already exists (1)")).toBeInTheDocument();
expect(screen.getByText("existing")).toBeInTheDocument();
});
it("imports additions and skips conflicts by default", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
// Default: 1 addition + 0 non-skipped conflicts.
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() =>
expect(screen.getByText("Import complete")).toBeInTheDocument(),
);
expect(h.onAddServer).toHaveBeenCalledWith("alpha", {
type: "stdio",
command: "a",
});
expect(h.onUpdateServer).not.toHaveBeenCalled();
const addedRow = screen.getByText("alpha").parentElement as HTMLElement;
expect(within(addedRow).getByText("Imported")).toBeInTheDocument();
const skippedRow = screen.getByText("existing")
.parentElement as HTMLElement;
expect(within(skippedRow).getByText("Skipped")).toBeInTheDocument();
});
it("lets the user skip an individual new server", async () => {
const user = userEvent.setup();
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
// Toggle the new server "alpha" to Skip (its row's segmented control).
const alphaRow = screen.getByText("alpha").parentElement as HTMLElement;
await user.click(within(alphaRow).getByText("Skip"));
// alpha skipped + conflict "existing" skipped by default → nothing to import.
expect(
screen.getByRole("button", { name: /Import 0 servers/ }),
).toBeDisabled();
});
it("skips a deselected new server while still importing others", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
const alphaRow = screen.getByText("alpha").parentElement as HTMLElement;
await user.click(within(alphaRow).getByText("Skip"));
// Overwrite the conflict so there's still one server to import.
await user.click(screen.getByText("Overwrite"));
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() => screen.getByText("Import complete"));
expect(h.onAddServer).not.toHaveBeenCalled();
expect(h.onUpdateServer).toHaveBeenCalledWith("existing", "existing", {
type: "stdio",
command: "e",
});
const alphaSummaryRow = screen.getByText("alpha")
.parentElement as HTMLElement;
expect(within(alphaSummaryRow).getByText("Skipped")).toBeInTheDocument();
});
it("overwrites a conflict when chosen", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
await user.click(screen.getByText("Overwrite"));
await user.click(screen.getByRole("button", { name: /Import 2 servers/ }));
await waitFor(() => screen.getByText("Import complete"));
expect(h.onUpdateServer).toHaveBeenCalledWith("existing", "existing", {
type: "stdio",
command: "e",
});
});
it("renames a conflict to the supplied id", async () => {
const user = userEvent.setup();
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
await user.click(screen.getByText("Rename"));
const renameInput = screen.getByLabelText("New id for existing");
// Default rename value is existing-2.
expect((renameInput as HTMLInputElement).value).toBe("existing-2");
await user.clear(renameInput);
await user.type(renameInput, "existing-copy");
await user.click(screen.getByRole("button", { name: /Import 2 servers/ }));
await waitFor(() => screen.getByText("Import complete"));
expect(h.onAddServer).toHaveBeenCalledWith("existing-copy", {
type: "stdio",
command: "e",
});
});
it("flags an invalid/colliding rename target and blocks import", async () => {
const user = userEvent.setup();
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
await user.click(screen.getByText("Rename"));
const renameInput = screen.getByLabelText("New id for existing");
// Collides with the incoming addition "alpha".
await user.clear(renameInput);
await user.type(renameInput, "alpha");
expect(await screen.findByText(/already in use/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Import \d server/ }),
).toBeDisabled();
// Invalid characters.
await user.clear(renameInput);
await user.type(renameInput, "bad id!");
expect(
await screen.findByText(/letters, numbers, hyphens/),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Import \d server/ }),
).toBeDisabled();
// A unique, valid id clears the error and re-enables import.
await user.clear(renameInput);
await user.type(renameInput, "existing-copy");
await waitFor(() =>
expect(
screen.getByRole("button", { name: /Import 2 servers/ }),
).toBeEnabled(),
);
});
it("reports a per-server failure in the summary", async () => {
const user = userEvent.setup();
const h = setup();
h.onAddServer.mockRejectedValueOnce(new Error("boom"));
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() => screen.getByText("Import complete"));
const failedRow = screen.getByText("alpha").parentElement as HTMLElement;
expect(within(failedRow).getByText("Failed")).toBeInTheDocument();
expect(screen.getByText("boom")).toBeInTheDocument();
});
it("shows the searched-paths notice when no config is found", async () => {
const user = userEvent.setup();
setup({
type: "cursor",
found: false,
searched: ["/home/u/.cursor/mcp.json"],
});
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/No config found/)).toBeInTheDocument(),
);
});
it("shows an error when the source has a parse error", async () => {
const user = userEvent.setup();
setup({
type: "cursor",
found: true,
error: "Invalid JSON: bad",
searched: ["/home/u/.cursor/mcp.json"],
});
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/Invalid JSON: bad/)).toBeInTheDocument(),
);
});
it("shows an error when the fetch rejects", async () => {
const user = userEvent.setup();
setup(new Error("network down"));
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/network down/)).toBeInTheDocument(),
);
});
it("parses an uploaded file (mcpServers) into review", async () => {
const user = userEvent.setup();
setup();
const file = new File(
[JSON.stringify({ mcpServers: { fromfile: { command: "x" } } })],
"mcp.json",
{ type: "application/json" },
);
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
await waitFor(() =>
expect(screen.getByText("fromfile")).toBeInTheDocument(),
);
});
it("parses an uploaded VS Code file (servers) into review", async () => {
const user = userEvent.setup();
setup();
const file = new File(
[JSON.stringify({ servers: { vscodesrv: { command: "x" } } })],
"mcp.json",
{ type: "application/json" },
);
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
await waitFor(() =>
expect(screen.getByText("vscodesrv")).toBeInTheDocument(),
);
});
it("shows an error for an unparseable uploaded file", async () => {
const user = userEvent.setup();
setup();
const file = new File(["{not json"], "mcp.json", {
type: "application/json",
});
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(input, file);
await waitFor(() =>
expect(screen.getByText(/Invalid JSON/)).toBeInTheDocument(),
);
});
it("reports when the selected source has no servers", async () => {
const user = userEvent.setup();
setup({
type: "cursor",
found: true,
searched: [],
config: { mcpServers: {} },
});
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
expect(screen.getByText(/No servers found/)).toBeInTheDocument(),
);
});
it("goes back from review to the source picker", async () => {
const user = userEvent.setup();
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
await user.click(screen.getByRole("button", { name: "Back" }));
// Back to the source picker (dropdown + Import button).
expect(screen.getByLabelText("Client")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Import" })).toBeInTheDocument();
});
it("closes from the summary via Done", async () => {
const user = userEvent.setup();
const h = setup({
type: "cursor",
found: true,
searched: [],
config: { mcpServers: { only: { command: "x" } } },
});
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
await user.click(screen.getByRole("button", { name: /Import 1 server/ }));
await waitFor(() => screen.getByText("Import complete"));
await user.click(screen.getByRole("button", { name: "Done" }));
expect(h.onClose).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,281 @@
import {
Alert,
Badge,
Button,
FileButton,
Group,
Loader,
Modal,
NativeSelect,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
IMPORT_STRATEGY_LIST,
type ConflictResolution,
type ImportSourceResult,
} from "@inspector/core/mcp/import/index.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import {
useImportClientConfig,
type AdditionAction,
type ImportOutcome,
} from "../../../hooks/useImportClientConfig";
export interface ServerImportConfigModalProps {
opened: boolean;
/** Ids already in the catalog — drives conflict detection + rename defaults. */
existingIds: string[];
onClose: () => void;
/** Read a client's well-known config on the backend (authed GET). */
onFetchSource: (type: string) => Promise<ImportSourceResult>;
onAddServer: (id: string, config: MCPServerConfig) => Promise<void>;
onUpdateServer: (
originalId: string,
newId: string,
config: MCPServerConfig,
) => Promise<void>;
}
const SectionStack = Stack.withProps({ gap: "md" });
const SourceRow = Group.withProps({
gap: "sm",
align: "flex-end",
wrap: "nowrap",
});
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
const RowGroup = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "sm",
});
const DimText = Text.withProps({ size: "sm", c: "dimmed" });
const ModalTitle = Text.withProps({ fw: 700, span: true });
/**
* Source-picker dropdown options, derived from the strategy registry. A leading
* empty option acts as the "nothing selected yet" placeholder (Import stays
* disabled until a real client is chosen).
*/
const SOURCE_OPTIONS = [
{ value: "", label: "Select a client…" },
...IMPORT_STRATEGY_LIST.map((s) => ({ value: s.id, label: s.label })),
];
const RESOLUTION_DATA = [
{ value: "overwrite", label: "Overwrite" },
{ value: "skip", label: "Skip" },
{ value: "rename", label: "Rename" },
];
const ADDITION_DATA = [
{ value: "import", label: "Import" },
{ value: "skip", label: "Skip" },
];
/** Display label + badge color for each per-server import outcome. */
const OUTCOME_META: Record<
ImportOutcome["status"],
{ label: string; color: string }
> = {
added: { label: "Imported", color: "green" },
overwritten: { label: "Overwritten", color: "blue" },
renamed: { label: "Renamed", color: "blue" },
skipped: { label: "Skipped", color: "gray" },
failed: { label: "Failed", color: "red" },
};
/**
* Display shell for the "Import from client config" flow (#1348): a source picker /
* file upload, a per-server review with conflict resolution, and an outcome
* summary. All orchestration lives in `useImportClientConfig`.
*/
export function ServerImportConfigModal({
opened,
existingIds,
onClose,
onFetchSource,
onAddServer,
onUpdateServer,
}: ServerImportConfigModalProps) {
const vm = useImportClientConfig({
opened,
existingIds,
onFetchSource,
onAddServer,
onUpdateServer,
});
const { plan } = vm;
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
centered
title={<ModalTitle>Import from client config</ModalTitle>}
>
<SectionStack>
{vm.error ? (
<Alert color="red" title="Import error">
{vm.error}
</Alert>
) : null}
{vm.notice ? <Alert color="yellow">{vm.notice}</Alert> : null}
{vm.phase === "select" ? (
<SectionStack>
<DimText>
Import MCP servers from another client. Choose a known client
(read on this machine) or upload its config file.
</DimText>
<SourceRow>
<NativeSelect
label="Client"
data={SOURCE_OPTIONS}
value={vm.selectedType ?? ""}
onChange={(e) =>
vm.setSelectedType(e.currentTarget.value || null)
}
flex={1}
/>
<Button
disabled={!vm.selectedType}
onClick={() => {
if (vm.selectedType) void vm.pickSource(vm.selectedType);
}}
>
Import
</Button>
<FileButton
accept="application/json,.json"
onChange={(file) => void vm.pickFile(file)}
>
{(props) => (
<Button {...props} variant="default">
From file…
</Button>
)}
</FileButton>
</SourceRow>
</SectionStack>
) : null}
{vm.phase === "loading" ? (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">Working…</Text>
</Group>
) : null}
{vm.phase === "review" && plan ? (
<SectionStack>
{plan.additions.length > 0 ? (
<Stack gap="xs">
<Text fw={600} size="sm">
New servers ({plan.additions.length})
</Text>
{plan.additions.map((a) => (
<RowGroup key={a.id}>
<Text size="sm">{a.id}</Text>
<SegmentedControl
size="xs"
data={ADDITION_DATA}
value={vm.additionActions[a.id] ?? "import"}
onChange={(value) =>
vm.setAdditionAction(a.id, value as AdditionAction)
}
/>
</RowGroup>
))}
</Stack>
) : null}
{plan.conflicts.length > 0 ? (
<Stack gap="xs">
<Text fw={600} size="sm">
Already exists ({plan.conflicts.length})
</Text>
{plan.conflicts.map((conflict) => {
const res = vm.resolutions[conflict.id];
return (
<Stack key={conflict.id} gap="xs">
<RowGroup>
<Text size="sm">{conflict.id}</Text>
<SegmentedControl
size="xs"
data={RESOLUTION_DATA}
value={res?.action ?? "skip"}
onChange={(value) =>
vm.setResolution(
conflict.id,
value as ConflictResolution,
)
}
/>
</RowGroup>
{res?.action === "rename" ? (
<TextInput
size="xs"
aria-label={`New id for ${conflict.id}`}
value={res.renameTo}
error={vm.renameErrors[conflict.id]}
onChange={(e) =>
vm.setRenameTo(conflict.id, e.currentTarget.value)
}
/>
) : null}
</Stack>
);
})}
</Stack>
) : null}
<Actions>
<Button variant="default" onClick={vm.back}>
Back
</Button>
<Button
onClick={() => void vm.runImport()}
disabled={!vm.canImport}
>
Import {vm.importCount} server{vm.importCount === 1 ? "" : "s"}
</Button>
</Actions>
</SectionStack>
) : null}
{vm.phase === "summary" ? (
<SectionStack>
<Text fw={600} size="sm">
Import complete
</Text>
{vm.outcomes.map((o) => {
const meta = OUTCOME_META[o.status];
return (
<Stack key={`${o.id}-${o.status}`} gap={2}>
<RowGroup>
<Text size="sm">{o.id}</Text>
<Badge color={meta.color} variant="light">
{meta.label}
</Badge>
</RowGroup>
{o.detail ? (
<Text size="xs" c="var(--inspector-status-error)">
{o.detail}
</Text>
) : null}
</Stack>
);
})}
<Actions>
<Button onClick={onClose}>Done</Button>
</Actions>
</SectionStack>
) : null}
</SectionStack>
</Modal>
);
}