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

View file

@ -4,6 +4,7 @@ import {
mcpConfigToServerEntries,
normalizeServerType,
serverEntriesToMcpConfig,
serializeMcpConfig,
} from "@inspector/core/mcp/serverList.js";
import type { MCPConfig, ServerEntry } from "@inspector/core/mcp/types.js";
@ -169,6 +170,42 @@ describe("serverEntriesToMcpConfig", () => {
});
});
describe("serializeMcpConfig", () => {
it("produces 2-space-indented canonical JSON", () => {
const json = serializeMcpConfig([
{
id: "alpha",
name: "alpha",
config: { type: "stdio", command: "node" },
connection: { status: "disconnected" },
},
]);
expect(json).toBe(
`{\n "mcpServers": {\n "alpha": {\n "type": "stdio",\n "command": "node"\n }\n }\n}`,
);
});
it("strips runtime-only fields (connection, info, name) from the output", () => {
const json = serializeMcpConfig([
{
id: "alpha",
name: "Alpha (pretty)",
config: { type: "stdio", command: "node" },
connection: { status: "connected" },
info: { name: "alpha-impl", version: "1.0.0" },
},
]);
const parsed = JSON.parse(json) as Record<string, unknown>;
expect(parsed).toEqual({
mcpServers: { alpha: { type: "stdio", command: "node" } },
});
});
it('returns `{ "mcpServers": {} }` for an empty list', () => {
expect(serializeMcpConfig([])).toBe(`{\n "mcpServers": {}\n}`);
});
});
describe("DEFAULT_SEED_CONFIG", () => {
it("contains the two canonical seed servers", () => {
expect(Object.keys(DEFAULT_SEED_CONFIG.mcpServers)).toEqual([

View file

@ -0,0 +1,94 @@
/**
* Tests for the downloadJsonFile helper. Mocks URL.createObjectURL +
* URL.revokeObjectURL (happy-dom doesn't ship them) and asserts the temp
* anchor's attributes + click + cleanup sequence.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { downloadJsonFile } from "../../lib/downloadFile";
describe("downloadJsonFile", () => {
const originalCreate = URL.createObjectURL;
const originalRevoke = URL.revokeObjectURL;
let createMock: ReturnType<typeof vi.fn>;
let revokeMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
createMock = vi.fn().mockReturnValue("blob:mock-url");
revokeMock = vi.fn();
// happy-dom doesn't implement URL.createObjectURL/revokeObjectURL.
URL.createObjectURL = createMock as unknown as typeof URL.createObjectURL;
URL.revokeObjectURL = revokeMock as unknown as typeof URL.revokeObjectURL;
});
afterEach(() => {
URL.createObjectURL = originalCreate;
URL.revokeObjectURL = originalRevoke;
vi.restoreAllMocks();
});
it("creates an anchor with the right href + download attributes and clicks it", () => {
const clickedAnchors: HTMLAnchorElement[] = [];
const origCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
const el = origCreateElement(tag) as HTMLAnchorElement;
if (tag === "a") {
el.click = function click() {
clickedAnchors.push(el);
};
}
return el;
}) as typeof document.createElement);
downloadJsonFile("mcp.json", '{"hello":"world"}');
expect(createMock).toHaveBeenCalledOnce();
const blob = createMock.mock.calls[0]?.[0] as Blob;
expect(blob).toBeInstanceOf(Blob);
expect(blob.type).toBe("application/json");
expect(clickedAnchors).toHaveLength(1);
const anchor = clickedAnchors[0]!;
expect(anchor.getAttribute("href")).toBe("blob:mock-url");
expect(anchor.getAttribute("download")).toBe("mcp.json");
// After the call, the anchor must be removed and the URL revoked.
expect(document.body.contains(anchor)).toBe(false);
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
});
it("still cleans up the anchor + URL when click() throws (try/finally guard)", () => {
const removed: Node[] = [];
const origCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
const el = origCreateElement(tag) as HTMLAnchorElement;
if (tag === "a") {
el.click = () => {
throw new Error("simulated click failure");
};
}
return el;
}) as typeof document.createElement);
// Stub removeChild outright so the throw doesn't double up with a
// "not a child" error from a partially-mocked DOM.
vi.spyOn(document.body, "removeChild").mockImplementation(((node: Node) => {
removed.push(node);
return node;
}) as typeof document.body.removeChild);
expect(() => downloadJsonFile("oops.json", '{"oops":true}')).toThrow(
/simulated click failure/,
);
// Cleanup still ran despite the throw.
expect(removed).toHaveLength(1);
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
});
it("passes the JSON string into the Blob constructor verbatim", async () => {
downloadJsonFile("test.json", '{"x":1}');
const blob = createMock.mock.calls[0]?.[0] as Blob;
const text = await blob.text();
expect(text).toBe('{"x":1}');
});
});