Skip to content

Commit 420da2e

Browse files
cliffhallclaude
andauthored
feat(servers): export server list as mcp.json download (#1346) (#1351)
* feat(servers): export server list as mcp.json download (#1346) Adds an Export button to the Servers screen, next to "Add Servers". Clicking it downloads the current server list as a canonical mcp.json file (2-space indent, same shape serializeStore writes), so the user can hand it off, sync it to another machine, or symlink into Claude Desktop / Cursor / Cline. Wiring: - ServerListControls: new Export Button (variant=default), disabled when serverCount === 0 (nothing to download). - ServerListScreen / InspectorView: thread `onExport` / `onServerExport` through. - App.tsx: onServerExport runs serverEntriesToMcpConfig on the in-memory servers list, JSON.stringify with 2-space indent, and triggers a Blob download via a temporary anchor (appended to body for Firefox compatibility, then removed; object URL revoked). Tests: - ServerListControls unit tests cover button-count (toggle hidden vs shown, Export always present), disabled-on-empty, click fires onExport. - ServerListControls.stories grows play functions for both stories — WithServers asserts Export is enabled + click fires the spy in real Chromium, WithoutServers asserts Export is disabled. - Existing screen + view tests / stories updated for the new prop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR #1351 review (extract helpers + exception safety + guard) Substantive items from the review: 1. Serialization format parity (review #1): added `serializeMcpConfig(entries)` to core/mcp/serverList.ts — a browser-safe helper that runs `serverEntriesToMcpConfig` then stringifies with 2-space indent. Single source of truth for export formatting; can't drift from serializeStore's on-disk shape. 2. Exception safety on the anchor cleanup (review #2) + a direct test for the App.tsx download wiring (review #6): extracted `downloadJsonFile(filename, json)` to clients/web/src/lib/ downloadFile.ts. removeChild + revokeObjectURL run in a `finally` so a thrown click() doesn't leak the DOM node or the object URL. Unit-tested under happy-dom with URL.createObjectURL mocked (happy-dom doesn't ship it). 3. Empty-list guard (review #5): App.tsx onServerExport bails when `servers.length === 0`. The button's disabled prop already prevents the UI path, but the handler is now locally correct against any future programmatic caller (storybook, keyboard shortcut, etc.). Acknowledged but not changed: - Tooltip on the disabled Export button (review #3) — Mantine's disabled-button + Tooltip wart wants a span wrapper + custom events config. Optional per the reviewer; deferred. - Repeated exports → `mcp (1).json` browser suffix (review #4) — a deliberate trade-off since `mcp.json` is the conventional name a user would expect to symlink into Claude Desktop / Cursor / etc. Tests: 1647 unit (+6) + 410 integration + 306 storybook = 2363 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db1916c commit 420da2e

14 files changed

Lines changed: 255 additions & 7 deletions

File tree

clients/web/src/App.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResource
2121
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
2222
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
2323
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
24+
import { serializeMcpConfig } from "@inspector/core/mcp/serverList.js";
2425
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
2526
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
2627
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
@@ -45,6 +46,7 @@ import {
4546
type ServerConfigModalMode,
4647
} from "./components/groups/ServerConfigModal/ServerConfigModal";
4748
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
49+
import { downloadJsonFile } from "./lib/downloadFile";
4850
import { createWebEnvironment } from "./lib/environmentFactory";
4951

5052
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
@@ -605,6 +607,18 @@ function App() {
605607
/* TODO: not wired yet */
606608
}, []);
607609

610+
// Download the current server list as a canonical mcp.json file. Uses the
611+
// in-memory `servers` list (kept in sync with disk by useServers' refresh-
612+
// after-mutate flow) so there's no extra HTTP roundtrip. Serialization
613+
// format (2-space indent) lives in serializeMcpConfig so the export
614+
// matches what serializeStore writes on the backend. The button is
615+
// disabled when the list is empty, but the guard here keeps the handler
616+
// locally correct against any future programmatic caller.
617+
const onServerExport = useCallback(() => {
618+
if (servers.length === 0) return;
619+
downloadJsonFile("mcp.json", serializeMcpConfig(servers));
620+
}, [servers]);
621+
608622
// Remove handler — runs after the user confirms in the modal. When removing
609623
// the active server, also tear down the session in-place so the client and
610624
// its 9 state managers can be GC'd now instead of lingering until the next
@@ -740,6 +754,7 @@ function App() {
740754
onServerAdd={() => setConfigModal({ mode: "add" })}
741755
onServerImportConfig={todoNoop}
742756
onServerImportJson={todoNoop}
757+
onServerExport={onServerExport}
743758
onServerInfo={todoNoop}
744759
onServerSettings={todoNoop}
745760
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}

clients/web/src/components/groups/ServerListControls/ServerListControls.stories.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
2-
import { fn } from "storybook/test";
2+
import { expect, fn, userEvent, within } from "storybook/test";
33
import { ServerListControls } from "./ServerListControls";
44

55
const meta: Meta<typeof ServerListControls> = {
@@ -18,6 +18,17 @@ export const WithServers: Story = {
1818
onAddManually: fn(),
1919
onImportConfig: fn(),
2020
onImportServerJson: fn(),
21+
onExport: fn(),
22+
},
23+
play: async ({ canvasElement, args }) => {
24+
// Real-Chromium regression guard: Export is enabled when servers exist,
25+
// and clicking it fires onExport. Unit tests cover the same path under
26+
// happy-dom; this catches anything browser-specific in the wiring.
27+
const body = within(canvasElement.ownerDocument.body);
28+
const exportBtn = await body.findByRole("button", { name: /Export/ });
29+
await expect(exportBtn).not.toBeDisabled();
30+
await userEvent.click(exportBtn);
31+
await expect(args.onExport).toHaveBeenCalledTimes(1);
2132
},
2233
};
2334

@@ -29,5 +40,11 @@ export const WithoutServers: Story = {
2940
onAddManually: fn(),
3041
onImportConfig: fn(),
3142
onImportServerJson: fn(),
43+
onExport: fn(),
44+
},
45+
play: async ({ canvasElement }) => {
46+
const body = within(canvasElement.ownerDocument.body);
47+
const exportBtn = await body.findByRole("button", { name: /Export/ });
48+
await expect(exportBtn).toBeDisabled();
3249
},
3350
};

clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,23 @@ const baseProps = {
1010
onAddManually: vi.fn(),
1111
onImportConfig: vi.fn(),
1212
onImportServerJson: vi.fn(),
13+
onExport: vi.fn(),
1314
};
1415

1516
describe("ServerListControls", () => {
16-
it("hides the list toggle when there are no servers", () => {
17+
it("hides the list toggle when there are no servers (Export + Add Servers remain)", () => {
1718
renderWithMantine(<ServerListControls {...baseProps} />);
1819
const buttons = screen.getAllByRole("button");
19-
expect(buttons).toHaveLength(1);
20-
expect(buttons[0]).toHaveAccessibleName(/Add Servers/);
20+
expect(buttons).toHaveLength(2);
21+
expect(screen.getByRole("button", { name: /Export/ })).toBeInTheDocument();
22+
expect(
23+
screen.getByRole("button", { name: /Add Servers/ }),
24+
).toBeInTheDocument();
2125
});
2226

23-
it("shows the list toggle when servers exist", () => {
27+
it("shows the list toggle alongside Export + Add Servers when servers exist", () => {
2428
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
25-
expect(screen.getAllByRole("button")).toHaveLength(2);
29+
expect(screen.getAllByRole("button")).toHaveLength(3);
2630
});
2731

2832
it("calls onToggleList when the list toggle is clicked", async () => {
@@ -39,4 +43,24 @@ describe("ServerListControls", () => {
3943
await user.click(buttons[0]);
4044
expect(onToggleList).toHaveBeenCalledTimes(1);
4145
});
46+
47+
it("disables Export when the list is empty (nothing to download)", () => {
48+
renderWithMantine(<ServerListControls {...baseProps} />);
49+
expect(screen.getByRole("button", { name: /Export/ })).toBeDisabled();
50+
});
51+
52+
it("enables Export when at least one server exists", () => {
53+
renderWithMantine(<ServerListControls {...baseProps} serverCount={1} />);
54+
expect(screen.getByRole("button", { name: /Export/ })).not.toBeDisabled();
55+
});
56+
57+
it("calls onExport when Export is clicked (with at least one server)", async () => {
58+
const user = userEvent.setup();
59+
const onExport = vi.fn();
60+
renderWithMantine(
61+
<ServerListControls {...baseProps} serverCount={1} onExport={onExport} />,
62+
);
63+
await user.click(screen.getByRole("button", { name: /Export/ }));
64+
expect(onExport).toHaveBeenCalledTimes(1);
65+
});
4266
});

clients/web/src/components/groups/ServerListControls/ServerListControls.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Group } from "@mantine/core";
1+
import { Button, Group } from "@mantine/core";
22
import { ListToggle } from "../../elements/ListToggle/ListToggle";
33
import {
44
ServerAddMenu,
@@ -9,6 +9,8 @@ export interface ServerListControlsProps extends AddServerMenuProps {
99
compact: boolean;
1010
serverCount: number;
1111
onToggleList: () => void;
12+
/** Download the current server list as a canonical `mcp.json` file. */
13+
onExport: () => void;
1214
}
1315

1416
export function ServerListControls({
@@ -18,12 +20,16 @@ export function ServerListControls({
1820
onAddManually,
1921
onImportConfig,
2022
onImportServerJson,
23+
onExport,
2124
}: ServerListControlsProps) {
2225
return (
2326
<Group justify="flex-end">
2427
{serverCount > 0 && (
2528
<ListToggle compact={compact} onToggle={onToggleList} />
2629
)}
30+
<Button variant="default" onClick={onExport} disabled={serverCount === 0}>
31+
Export
32+
</Button>
2733
<ServerAddMenu
2834
onAddManually={onAddManually}
2935
onImportConfig={onImportConfig}

clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const meta: Meta<typeof ServerListScreen> = {
1111
onAddManually: fn(),
1212
onImportConfig: fn(),
1313
onImportServerJson: fn(),
14+
onExport: fn(),
1415
onToggleConnection: fn(),
1516
onServerInfo: fn(),
1617
onSettings: fn(),

clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const baseProps = {
1818
onAddManually: vi.fn(),
1919
onImportConfig: vi.fn(),
2020
onImportServerJson: vi.fn(),
21+
onExport: vi.fn(),
2122
onToggleConnection: vi.fn(),
2223
onServerInfo: vi.fn(),
2324
onSettings: vi.fn(),

clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface ServerListScreenProps {
1111
onAddManually: () => void;
1212
onImportConfig: () => void;
1313
onImportServerJson: () => void;
14+
/** Download the current server list as a canonical `mcp.json` file. */
15+
onExport: () => void;
1416
onToggleConnection: (id: string) => void;
1517
onServerInfo: (id: string) => void;
1618
onSettings: (id: string) => void;
@@ -35,6 +37,7 @@ export function ServerListScreen({
3537
onAddManually,
3638
onImportConfig,
3739
onImportServerJson,
40+
onExport,
3841
onToggleConnection,
3942
onServerInfo,
4043
onSettings,
@@ -57,6 +60,7 @@ export function ServerListScreen({
5760
onAddManually={onAddManually}
5861
onImportConfig={onImportConfig}
5962
onImportServerJson={onImportServerJson}
63+
onExport={onExport}
6064
/>
6165

6266
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 60px) - var(--mantine-spacing-xl) * 2 - 60px)">

clients/web/src/components/views/InspectorView/InspectorView.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ const meta: Meta<typeof InspectorView> = {
294294
onServerAdd: fn(),
295295
onServerImportConfig: fn(),
296296
onServerImportJson: fn(),
297+
onServerExport: fn(),
297298
onServerInfo: fn(),
298299
onServerSettings: fn(),
299300
onServerEdit: fn(),

clients/web/src/components/views/InspectorView/InspectorView.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function makeProps(
5858
onServerAdd: vi.fn(),
5959
onServerImportConfig: vi.fn(),
6060
onServerImportJson: vi.fn(),
61+
onServerExport: vi.fn(),
6162
onServerInfo: vi.fn(),
6263
onServerSettings: vi.fn(),
6364
onServerEdit: vi.fn(),

clients/web/src/components/views/InspectorView/InspectorView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export interface InspectorViewProps {
154154
onServerAdd: () => void;
155155
onServerImportConfig: () => void;
156156
onServerImportJson: () => void;
157+
/** Download the current server list as a canonical `mcp.json` file. */
158+
onServerExport: () => void;
157159
onServerInfo: (id: string) => void;
158160
onServerSettings: (id: string) => void;
159161
onServerEdit: (id: string) => void;
@@ -233,6 +235,7 @@ export function InspectorView({
233235
onServerAdd,
234236
onServerImportConfig,
235237
onServerImportJson,
238+
onServerExport,
236239
onServerInfo,
237240
onServerSettings,
238241
onServerEdit,
@@ -356,6 +359,7 @@ export function InspectorView({
356359
onAddManually={onServerAdd}
357360
onImportConfig={onServerImportConfig}
358361
onImportServerJson={onServerImportJson}
362+
onExport={onServerExport}
359363
onToggleConnection={onToggleConnection}
360364
onServerInfo={onServerInfo}
361365
onSettings={onServerSettings}

0 commit comments

Comments
 (0)