* 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:
parent
5efe9ddc7b
commit
97b1a7fbff
3 changed files with 4238 additions and 176 deletions
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue