Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResource
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
import { serializeMcpConfig } from "@inspector/core/mcp/serverList.js";
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
Expand All @@ -45,6 +46,7 @@ import {
type ServerConfigModalMode,
} from "./components/groups/ServerConfigModal/ServerConfigModal";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
import { downloadJsonFile } from "./lib/downloadFile";
import { createWebEnvironment } from "./lib/environmentFactory";

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

// Download the current server list as a canonical mcp.json file. Uses the
// in-memory `servers` list (kept in sync with disk by useServers' refresh-
// after-mutate flow) so there's no extra HTTP roundtrip. Serialization
// format (2-space indent) lives in serializeMcpConfig so the export
// matches what serializeStore writes on the backend. The button is
// disabled when the list is empty, but the guard here keeps the handler
// locally correct against any future programmatic caller.
const onServerExport = useCallback(() => {
if (servers.length === 0) return;
downloadJsonFile("mcp.json", serializeMcpConfig(servers));
}, [servers]);

// Remove handler — runs after the user confirms in the modal. When removing
// the active server, also tear down the session in-place so the client and
// its 9 state managers can be GC'd now instead of lingering until the next
Expand Down Expand Up @@ -740,6 +754,7 @@ function App() {
onServerAdd={() => setConfigModal({ mode: "add" })}
onServerImportConfig={todoNoop}
onServerImportJson={todoNoop}
onServerExport={onServerExport}
onServerInfo={todoNoop}
onServerSettings={todoNoop}
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {
Expand All @@ -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);
},
};

Expand All @@ -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();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Group } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
ServerAddMenu,
Expand All @@ -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({
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const meta: Meta<typeof ServerListScreen> = {
onAddManually: fn(),
onImportConfig: fn(),
onImportServerJson: fn(),
onExport: fn(),
onToggleConnection: fn(),
onServerInfo: fn(),
onSettings: fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const baseProps = {
onAddManually: vi.fn(),
onImportConfig: vi.fn(),
onImportServerJson: vi.fn(),
onExport: vi.fn(),
onToggleConnection: vi.fn(),
onServerInfo: vi.fn(),
onSettings: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface ServerListScreenProps {
onAddManually: () => void;
onImportConfig: () => void;
onImportServerJson: () => void;
/** Download the current server list as a canonical `mcp.json` file. */
onExport: () => void;
onToggleConnection: (id: string) => void;
onServerInfo: (id: string) => void;
onSettings: (id: string) => void;
Expand All @@ -35,6 +37,7 @@ export function ServerListScreen({
onAddManually,
onImportConfig,
onImportServerJson,
onExport,
onToggleConnection,
onServerInfo,
onSettings,
Expand All @@ -57,6 +60,7 @@ export function ServerListScreen({
onAddManually={onAddManually}
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
onExport={onExport}
/>

<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 60px) - var(--mantine-spacing-xl) * 2 - 60px)">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ const meta: Meta<typeof InspectorView> = {
onServerAdd: fn(),
onServerImportConfig: fn(),
onServerImportJson: fn(),
onServerExport: fn(),
onServerInfo: fn(),
onServerSettings: fn(),
onServerEdit: fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function makeProps(
onServerAdd: vi.fn(),
onServerImportConfig: vi.fn(),
onServerImportJson: vi.fn(),
onServerExport: vi.fn(),
onServerInfo: vi.fn(),
onServerSettings: vi.fn(),
onServerEdit: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export interface InspectorViewProps {
onServerAdd: () => void;
onServerImportConfig: () => void;
onServerImportJson: () => void;
/** Download the current server list as a canonical `mcp.json` file. */
onServerExport: () => void;
onServerInfo: (id: string) => void;
onServerSettings: (id: string) => void;
onServerEdit: (id: string) => void;
Expand Down Expand Up @@ -233,6 +235,7 @@ export function InspectorView({
onServerAdd,
onServerImportConfig,
onServerImportJson,
onServerExport,
onServerInfo,
onServerSettings,
onServerEdit,
Expand Down Expand Up @@ -356,6 +359,7 @@ export function InspectorView({
onAddManually={onServerAdd}
onImportConfig={onServerImportConfig}
onImportServerJson={onServerImportJson}
onExport={onServerExport}
onToggleConnection={onToggleConnection}
onServerInfo={onServerInfo}
onSettings={onServerSettings}
Expand Down
32 changes: 32 additions & 0 deletions clients/web/src/lib/downloadFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Browser-side helpers for triggering file downloads from in-memory content.
*
* Centralized so the temp-anchor incantation (`appendChild` for Firefox,
* try/finally to keep cleanup bulletproof, `revokeObjectURL` to release the
* object URL) lives in one place — and so the wiring is unit-testable
* under happy-dom without dragging React along.
*/

/**
* Download an in-memory string as a JSON file. Uses a temporary anchor
* element to trigger the browser's save dialog. The append-to-body step is
* for older Firefox versions that wouldn't fire `click()` on a detached
* anchor; modern browsers don't require it but it stays as the safe path.
*
* The cleanup (removeChild + revokeObjectURL) runs in a `finally` so even
* a thrown `click()` doesn't leak the DOM node or the object URL.
*/
export function downloadJsonFile(filename: string, json: string): void {
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
try {
anchor.click();
} finally {
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
}
37 changes: 37 additions & 0 deletions clients/web/src/test/core/mcp/serverList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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([
Expand Down
Loading
Loading