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>
This commit is contained in:
Cliff Hall 2026-05-23 15:15:56 -04:00 • committed by GitHub
commit 420da2e415
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 255 additions and 7 deletions

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import { ServerListControls } from "./ServerListControls";
const meta: Meta<typeof ServerListControls> = {
@ -18,6 +18,17 @@ export const WithServers: Story = {
onAddManually: fn(),
onImportConfig: fn(),
onImportServerJson: fn(),
onExport: fn(),
},
play: async ({ canvasElement, args }) => {
// Real-Chromium regression guard: Export is enabled when servers exist,
// and clicking it fires onExport. Unit tests cover the same path under
// happy-dom; this catches anything browser-specific in the wiring.
const body = within(canvasElement.ownerDocument.body);
const exportBtn = await body.findByRole("button", { name: /Export/ });
await expect(exportBtn).not.toBeDisabled();
await userEvent.click(exportBtn);
await expect(args.onExport).toHaveBeenCalledTimes(1);
},
};
@ -29,5 +40,11 @@ export const WithoutServers: Story = {
onAddManually: fn(),
onImportConfig: fn(),
onImportServerJson: fn(),
onExport: fn(),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const exportBtn = await body.findByRole("button", { name: /Export/ });
await expect(exportBtn).toBeDisabled();
},
};

View file

@ -10,19 +10,23 @@ const baseProps = {
onAddManually: vi.fn(),
onImportConfig: vi.fn(),
onImportServerJson: vi.fn(),
onExport: vi.fn(),
};
describe("ServerListControls", () => {
it("hides the list toggle when there are no servers", () => {
it("hides the list toggle when there are no servers (Export + Add Servers remain)", () => {
renderWithMantine(<ServerListControls {...baseProps} />);
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveAccessibleName(/Add Servers/);
expect(buttons).toHaveLength(2);
expect(screen.getByRole("button", { name: /Export/ })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Add Servers/ }),
).toBeInTheDocument();
});
it("shows the list toggle when servers exist", () => {
it("shows the list toggle alongside Export + Add Servers when servers exist", () => {
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
expect(screen.getAllByRole("button")).toHaveLength(2);
expect(screen.getAllByRole("button")).toHaveLength(3);
});
it("calls onToggleList when the list toggle is clicked", async () => {
@ -39,4 +43,24 @@ describe("ServerListControls", () => {
await user.click(buttons[0]);
expect(onToggleList).toHaveBeenCalledTimes(1);
});
it("disables Export when the list is empty (nothing to download)", () => {
renderWithMantine(<ServerListControls {...baseProps} />);
expect(screen.getByRole("button", { name: /Export/ })).toBeDisabled();
});
it("enables Export when at least one server exists", () => {
renderWithMantine(<ServerListControls {...baseProps} serverCount={1} />);
expect(screen.getByRole("button", { name: /Export/ })).not.toBeDisabled();
});
it("calls onExport when Export is clicked (with at least one server)", async () => {
const user = userEvent.setup();
const onExport = vi.fn();
renderWithMantine(
<ServerListControls {...baseProps} serverCount={1} onExport={onExport} />,
);
await user.click(screen.getByRole("button", { name: /Export/ }));
expect(onExport).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,4 +1,4 @@
import { Group } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
ServerAddMenu,
@ -9,6 +9,8 @@ export interface ServerListControlsProps extends AddServerMenuProps {
compact: boolean;
serverCount: number;
onToggleList: () => void;
/** Download the current server list as a canonical `mcp.json` file. */
onExport: () => void;
}
export function ServerListControls({
@ -18,12 +20,16 @@ export function ServerListControls({
onAddManually,
onImportConfig,
onImportServerJson,
onExport,
}: ServerListControlsProps) {
return (
<Group justify="flex-end">
{serverCount > 0 && (
<ListToggle compact={compact} onToggle={onToggleList} />
)}
<Button variant="default" onClick={onExport} disabled={serverCount === 0}>
Export
</Button>
<ServerAddMenu
onAddManually={onAddManually}
onImportConfig={onImportConfig}