feat(servers): persist server list to ~/.mcp-inspector/mcp.json (#1343) (#1349)

* feat(servers): persist server list to ~/.mcp-inspector/mcp.json (#1343)

Replaces the hardcoded SEED_SERVERS in App.tsx with a file-backed list at
~/.mcp-inspector/mcp.json. Full CRUD round-trips through new /api/servers
routes; first launch writes the two existing seeds if the file is absent.

Backend (core):
- store-io: getDefaultMcpConfigPath() — sibling of getDefaultStorageDir()
- core/mcp/serverList.ts: pure converters MCPConfig <-> ServerEntry[],
  DEFAULT_SEED_CONFIG, and normalizeServerType (moved from node/config.ts
  to keep serverList.ts Node-free)
- core/mcp/remote/node/server.ts: GET/POST/PUT/:id/DELETE/:id /api/servers
  routes, plus RemoteServerOptions.mcpConfigPath defaulting to
  getDefaultMcpConfigPath(). Ids validated with validateStoreId.

Frontend:
- core/react/useServers.ts: { servers, loading, error, refresh, addServer,
  updateServer, removeServer } over the new endpoints
- App.tsx: drops SEED_SERVERS, wires useServers, disconnects on remove of
  the active server, follows id on rename of the active server
- ServerConfigModal: Add/Edit/Clone form (stdio: command/args/env/cwd;
  sse/streamable-http: url/headers as one-per-line text)
- ServerRemoveConfirmModal: confirm-on-remove with id + transport summary

Tests (52 new):
- unit: getDefaultMcpConfigPath env-var permutations, serverList round-trip,
  hook flows against real Hono app.fetch (no TCP), modal forms + validation
- integration: /api/servers routes against a real createRemoteApp + tmp file
- stories with play functions for both new modals

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR #1349 review (correctness + nits)

Bugs:
- normalizeServerType: validate against the allowed set and default
  unknown / non-string types to "stdio" (matches the missing-type case).
  Prevents a hand-edited {"type":"websocket"} from leaking past `as
  ServerType` and surprising downstream narrowing.
- onConfirmRemove: when removing the active server, also destroy() the 9
  state managers and null them + the client out so they GC immediately
  instead of waiting for the next server switch.
- /api/servers mutating routes: serialize POST/PUT/DELETE through an
  in-process write-lock so concurrent read-modify-write cycles can't lose
  updates. New integration test fires 25 parallel POSTs and asserts every
  entry lands.
- readMcpConfig comment: update to match actual behavior (valid-JSON file
  with no `mcpServers` → empty; invalid JSON → 500 from the outer try, so
  corruption surfaces rather than silently emptying the list). New
  integration tests pin the 500 path on both GET and POST.

Nits:
- Add --inspector-surface-subtle token, use it in ServerRemoveConfirmModal
  instead of the raw Mantine var.
- Rename useServers buildHeaders boolean param to includeJsonBody.
- Add a comment on the env-value vs header-value trim asymmetry (env
  values preserve trailing whitespace; header values follow RFC 7230).

Tests: 1630 unit + 404 integration + 306 storybook all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ServerConfigModal): null currentTarget in functional setState updater

React nulls SyntheticEvent.currentTarget after the synchronous handler
returns. Reading e.currentTarget.value inside `setForm((f) => ...)`
worked under happy-dom (which keeps currentTarget around) but threw
"Cannot read properties of null (reading 'value')" on the first
keystroke in a real browser. Capture the value before the functional
updater closes over it.

All seven onChange handlers in ServerConfigModal had the bug. Extended
the AddEmpty storybook play to type into Server ID and Command — the
real-Chromium runner now exercises this path so a future regression
fails the test suite instead of the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(remote-server): surface stderr + transport_error when subprocess crashes during startup

A stdio subprocess that spawns successfully but exits before the browser
opens the SSE events stream used to vanish into a bare "Session not
found" 404. The fix has three parts:

1. RemoteSession.markTransportDead always pushes the transport_error
   event (via pushEvent's queue) rather than dropping it when no
   consumer is attached. The queued event drains when the consumer
   eventually arrives.

2. /api/mcp/connect's transport.onclose handler no longer deletes the
   session immediately when no consumer is attached. Instead it holds
   the session (with queued stderr + transport_error) for a 30s grace
   window so the events endpoint can drain it. A timeout sweeps if no
   consumer ever connects.

3. /api/mcp/events detects isTransportDead after attaching the consumer
   (which just drained the queue), yields once to flush, then closes
   the stream and deletes the session.

End result: the user sees the actual error (e.g. "Cannot find module"
from stderr + the transport-closed reason) instead of a confusing 404.

Tests:
- New unit tests (clients/web/src/test/integration/mcp/remote/remote-session.test.ts)
  cover the queueing behavior, live delivery, and clearEventConsumer
  signaling under all transport-state permutations.
- New integration test (connect-crash.test.ts) spawns a node subprocess
  that writes to stderr and exits, then verifies the events stream
  delivers both the stderr line and the transport_error event before
  closing — the exact scenario from PR #1349.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(state): gate listRequestorTasks on the server's `tasks` capability

ManagedRequestorTasksState was calling tasks/list on every connect.
Servers that don't advertise the `tasks` capability (the common case)
reply with -32601 "Method not found", which then surfaces in the
inspector's console for every connect.

Skip the call when the server doesn't declare `tasks` — empty tasks
list is the right semantics. Existing tests opt into capabilities so
the live-path coverage is preserved; two new tests pin the gated
behavior (direct refresh + connect-event-triggered refresh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(App): clear activeServerId on disconnect so other server cards re-enable

ServerCard dims any card whose id differs from `activeServer` (and marks
it inert), so cards stay non-interactive after a disconnect until the
user reconnects elsewhere. activeServerId was set on connect but never
reset on disconnect, leaving the whole screen locked to the just-
disconnected card.

Subscribe to InspectorClient's `disconnect` event and clear
activeServerId there. Covers all three disconnect paths uniformly:

- user toggles the connected card off
- user clicks the header Disconnect button
- mid-session transport failure (subprocess exit / network drop)

Event-based instead of effect-based on connectionStatus to avoid the
first-render-clobbers-new-id trap (status starts as "disconnected" for
a freshly-created client before connect() runs).

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 14:26:23 -04:00 • committed by GitHub
commit db1916cd34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3176 additions and 137 deletions

View file

@ -25,6 +25,9 @@
--inspector-surface-body: var(--mantine-color-dark-9);
--inspector-surface-card: var(--mantine-color-gray-0);
--inspector-surface-code: var(--mantine-color-white);
/* Subtle inset surface for modal call-outs / summary panels. Mantine's
`default-hover` already adapts to color scheme, so no dark override. */
--inspector-surface-subtle: var(--mantine-color-default-hover);
/* ── Inputs ────────────────────────────────────────────── */
--inspector-input-background: var(--mantine-color-white);

View file

@ -9,7 +9,11 @@ import type {
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { MessageEntry, ServerEntry } from "@inspector/core/mcp/types.js";
import type {
MCPServerConfig,
MessageEntry,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import { API_SERVER_ENV_VARS } from "@inspector/core/mcp/remote/constants.js";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js";
@ -22,6 +26,7 @@ import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogS
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useServers } from "@inspector/core/react/useServers.js";
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
@ -35,38 +40,13 @@ import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsS
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
import type { BridgeFactory } from "./components/elements/AppRenderer/AppRenderer";
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
import {
ServerConfigModal,
type ServerConfigModalMode,
} from "./components/groups/ServerConfigModal/ServerConfigModal";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
import { createWebEnvironment } from "./lib/environmentFactory";
// Hardcoded seed servers so the Servers screen has something to connect to.
// Persistence + an "Add server" UI are explicitly out of scope for #1244 (the
// useServers v2-only hook is a separate effort); follow-up work will replace
// this with a real `useServers` store. The two seeds here cover the common
// shapes a developer reaches for first: a real filesystem (scoped to /tmp so
// nothing destructive is possible by default) and the canonical "everything"
// reference server (tools / prompts / resources / sampling / completion).
const SEED_SERVERS: ServerEntry[] = [
{
id: "filesystem-server-default",
name: "Local Filesystem (npx)",
config: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
connection: { status: "disconnected" },
},
{
id: "everything-server-default",
name: "Everything (npx)",
config: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-everything"],
},
connection: { status: "disconnected" },
},
];
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
// handler. The InspectorClient only consults this when the active server
// requires OAuth; for stdio MCP servers it's never used. Created once and
@ -150,9 +130,24 @@ function App() {
setColorScheme(isDark ? "light" : "dark");
}, [isDark, setColorScheme]);
// Server list — held locally; one seed entry per #1244's "hardcoded
// sample server" scope decision. Future work: useServers hook.
const [servers] = useState<ServerEntry[]>(SEED_SERVERS);
// Server list — sourced from ~/.mcp-inspector/mcp.json via the backend's
// `/api/servers` routes. First-launch seeds are written by the backend when
// the file is absent, so this hook returns a non-empty list on first load.
const { servers, addServer, updateServer, removeServer } = useServers({
baseUrl:
typeof window !== "undefined"
? window.location.origin
: "http://localhost",
authToken: getAuthToken(),
});
// CRUD-modal state. `configModal` drives Add / Edit / Clone via a single
// shared form modal; `removeTarget` drives the remove-confirmation modal.
const [configModal, setConfigModal] = useState<{
mode: ServerConfigModalMode;
targetId?: string;
} | null>(null);
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(null);
// The active connection target. `null` between sessions; set as soon as
// the user toggles a server card on. Drives state-manager lifetime.
@ -277,6 +272,25 @@ function App() {
};
}, [inspectorClient]);
// Reset activeServerId whenever the live session ends. Without this the
// other ServerCards stay `inert` after disconnect — ServerCard dims any
// card whose id differs from `activeServer`. Subscribing to
// InspectorClient's own `disconnect` event covers all three paths
// (explicit toggle, header Disconnect button, mid-session transport
// failure / process exit) and avoids the first-render-clobbers-new-id
// trap that watching connectionStatus has (status starts as
// "disconnected" for the new client before connect() runs).
useEffect(() => {
if (!inspectorClient) return;
const onDisconnect = () => {
setActiveServerId(undefined);
};
inspectorClient.addEventListener("disconnect", onDisconnect);
return () => {
inspectorClient.removeEventListener("disconnect", onDisconnect);
};
}, [inspectorClient]);
// Build the InitializeResult the connected ViewHeader expects from the
// hook's split fields. `protocolVersion` is hard-coded for now — the
// useInspectorClient hook doesn't expose it. TODO(#1324): consume the
@ -591,6 +605,92 @@ function App() {
/* TODO: not wired yet */
}, []);
// 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
// server switch. Mirrors the destroy sequence at the top of
// `setupClientForServer` (lines ~304-312) but additionally nulls every ref.
const onConfirmRemove = useCallback(async () => {
if (!removeTarget) return;
const id = removeTarget.id;
if (id === activeServerId) {
if (inspectorClient) {
await inspectorClient.disconnect();
}
managedToolsState?.destroy();
managedPromptsState?.destroy();
managedResourcesState?.destroy();
managedResourceTemplatesState?.destroy();
managedRequestorTasksState?.destroy();
resourceSubscriptionsState?.destroy();
messageLogState?.destroy();
fetchRequestLogState?.destroy();
stderrLogState?.destroy();
setInspectorClient(null);
setManagedToolsState(null);
setManagedPromptsState(null);
setManagedResourcesState(null);
setManagedResourceTemplatesState(null);
setManagedRequestorTasksState(null);
setResourceSubscriptionsState(null);
setMessageLogState(null);
setFetchRequestLogState(null);
setStderrLogState(null);
setActiveServerId(undefined);
}
await removeServer(id);
setRemoveTarget(null);
}, [
removeTarget,
activeServerId,
inspectorClient,
managedToolsState,
managedPromptsState,
managedResourcesState,
managedResourceTemplatesState,
managedRequestorTasksState,
resourceSubscriptionsState,
messageLogState,
fetchRequestLogState,
stderrLogState,
removeServer,
]);
// Submit handler for the Add / Edit / Clone modal. Add and Clone both go
// through addServer; Edit uses updateServer (which supports id rename).
// On rename of the active server, keep activeServerId pointed at the new id.
const onConfigSubmit = useCallback(
async (id: string, config: MCPServerConfig) => {
if (configModal?.mode === "edit" && configModal.targetId) {
const originalId = configModal.targetId;
await updateServer(originalId, id, config);
if (originalId === activeServerId && id !== originalId) {
setActiveServerId(id);
}
return;
}
// add or clone
await addServer(id, config);
},
[configModal, addServer, updateServer, activeServerId],
);
// Derive the existingIds list the modal uses for uniqueness validation.
// In edit mode the target's own id must be excluded so saving without
// renaming doesn't trip the "already exists" check.
const existingIds = useMemo(() => {
const ids = servers.map((s) => s.id);
if (configModal?.mode === "edit" && configModal.targetId) {
return ids.filter((id) => id !== configModal.targetId);
}
return ids;
}, [servers, configModal]);
const configModalTarget = useMemo(() => {
if (!configModal?.targetId) return undefined;
return servers.find((s) => s.id === configModal.targetId);
}, [configModal, servers]);
// The Resources screen needs `isSubscribed` to flip the Subscribe button
// label to "Unsubscribe". Derive it from the live subscriptions list rather
// than threading it through every setReadResourceState site — that way the
@ -608,75 +708,95 @@ function App() {
}, [readResourceState, subscriptions]);
return (
<InspectorView
servers={servers}
activeServer={activeServerId}
connectionStatus={connectionStatus}
initializeResult={initializeResult}
latencyMs={latencyMs}
errorMessage={errorMessage}
tools={tools}
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}
subscriptions={subscriptions}
logs={logs}
tasks={tasks}
history={messages}
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={effectiveReadResourceState}
currentLogLevel={currentLogLevel}
sandboxPath={STUB_SANDBOX_PATH}
bridgeFactory={stubBridgeFactory}
onToggleTheme={onToggleTheme}
onToggleConnection={(id) => {
void onToggleConnection(id);
}}
onDisconnect={() => {
void onDisconnect();
}}
onServerAdd={todoNoop}
onServerImportConfig={todoNoop}
onServerImportJson={todoNoop}
onServerInfo={todoNoop}
onServerSettings={todoNoop}
onServerEdit={todoNoop}
onServerClone={todoNoop}
onServerRemove={todoNoop}
onCallTool={(name, args) => {
void onCallTool(name, args);
}}
onClearToolResult={onClearToolResult}
onRefreshTools={onRefreshTools}
onGetPrompt={(name, args) => {
void onGetPrompt(name, args);
}}
onRefreshPrompts={onRefreshPrompts}
onReadResource={(uri) => {
void onReadResource(uri);
}}
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
onCancelTask={onCancelTask}
onClearCompletedTasks={todoNoop}
onRefreshTasks={onRefreshTasks}
onSetLogLevel={onSetLogLevel}
onClearLogs={onClearLogs}
onExportLogs={todoNoop}
onCopyAllLogs={todoNoop}
onClearHistory={onClearHistory}
onExportHistory={todoNoop}
onReplayHistory={todoNoop}
onTogglePinHistory={todoNoop}
onSelectApp={todoNoop}
onOpenApp={todoNoop}
onCloseApp={todoNoop}
onRefreshApps={onRefreshTools}
/>
<>
<InspectorView
servers={servers}
activeServer={activeServerId}
connectionStatus={connectionStatus}
initializeResult={initializeResult}
latencyMs={latencyMs}
errorMessage={errorMessage}
tools={tools}
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}
subscriptions={subscriptions}
logs={logs}
tasks={tasks}
history={messages}
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={effectiveReadResourceState}
currentLogLevel={currentLogLevel}
sandboxPath={STUB_SANDBOX_PATH}
bridgeFactory={stubBridgeFactory}
onToggleTheme={onToggleTheme}
onToggleConnection={(id) => {
void onToggleConnection(id);
}}
onDisconnect={() => {
void onDisconnect();
}}
onServerAdd={() => setConfigModal({ mode: "add" })}
onServerImportConfig={todoNoop}
onServerImportJson={todoNoop}
onServerInfo={todoNoop}
onServerSettings={todoNoop}
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
onServerClone={(id) => setConfigModal({ mode: "clone", targetId: id })}
onServerRemove={(id) => {
const target = servers.find((s) => s.id === id);
if (target) setRemoveTarget(target);
}}
onCallTool={(name, args) => {
void onCallTool(name, args);
}}
onClearToolResult={onClearToolResult}
onRefreshTools={onRefreshTools}
onGetPrompt={(name, args) => {
void onGetPrompt(name, args);
}}
onRefreshPrompts={onRefreshPrompts}
onReadResource={(uri) => {
void onReadResource(uri);
}}
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
onCancelTask={onCancelTask}
onClearCompletedTasks={todoNoop}
onRefreshTasks={onRefreshTasks}
onSetLogLevel={onSetLogLevel}
onClearLogs={onClearLogs}
onExportLogs={todoNoop}
onCopyAllLogs={todoNoop}
onClearHistory={onClearHistory}
onExportHistory={todoNoop}
onReplayHistory={todoNoop}
onTogglePinHistory={todoNoop}
onSelectApp={todoNoop}
onOpenApp={todoNoop}
onCloseApp={todoNoop}
onRefreshApps={onRefreshTools}
/>
<ServerConfigModal
opened={configModal !== null}
mode={configModal?.mode ?? "add"}
initialId={configModalTarget?.id}
initialConfig={configModalTarget?.config}
existingIds={existingIds}
onClose={() => setConfigModal(null)}
onSubmit={onConfigSubmit}
/>
<ServerRemoveConfirmModal
opened={removeTarget !== null}
target={removeTarget}
onCancel={() => setRemoveTarget(null)}
onConfirm={onConfirmRemove}
/>
</>
);
}

View file

@ -0,0 +1,125 @@
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 { MCPServerConfig } from "@inspector/core/mcp/types.js";
import {
ServerConfigModal,
type ServerConfigModalProps,
} from "./ServerConfigModal";
function InteractiveRender(args: ServerConfigModalProps) {
const [, updateArgs] = useArgs<ServerConfigModalProps>();
return (
<AppShell>
<AppShell.Main>
<ServerConfigModal
{...args}
onClose={() => {
args.onClose();
updateArgs({ opened: false });
}}
onSubmit={async (id, config) => {
await args.onSubmit(id, config);
updateArgs({ opened: false });
}}
/>
</AppShell.Main>
</AppShell>
);
}
const stdioConfig: MCPServerConfig = {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-everything"],
env: { DEBUG: "1" },
};
const sseConfig: MCPServerConfig = {
type: "sse",
url: "https://example.com/sse",
headers: { Authorization: "Bearer xxx" },
};
const meta: Meta<typeof ServerConfigModal> = {
title: "Groups/ServerConfigModal",
component: ServerConfigModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
existingIds: [],
onClose: fn(),
onSubmit: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ServerConfigModal>;
export const AddEmpty: Story = {
args: { mode: "add" },
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("Add server")).toBeInTheDocument();
const idInput = body.getByLabelText(/Server ID/i) as HTMLInputElement;
await expect(idInput).toBeInTheDocument();
await expect(body.getByLabelText(/Command/i)).toBeInTheDocument();
// Regression guard for the synthetic-event currentTarget bug — happy-dom
// doesn't null currentTarget after the handler returns, so unit tests
// sail past it. Real Chromium (here) does, so any future onChange that
// reads e.currentTarget inside a setState updater will throw here.
await userEvent.type(idInput, "my-server");
await expect(idInput.value).toBe("my-server");
const cmdInput = body.getByLabelText(/Command/i) as HTMLInputElement;
await userEvent.type(cmdInput, "node");
await expect(cmdInput.value).toBe("node");
},
};
export const EditStdio: Story = {
args: {
mode: "edit",
initialId: "filesystem-server-default",
initialConfig: stdioConfig,
existingIds: ["other-server"],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("Edit server")).toBeInTheDocument();
const idInput = body.getByLabelText(/Server ID/i) as HTMLInputElement;
await expect(idInput.value).toBe("filesystem-server-default");
},
};
export const CloneStdio: Story = {
args: {
mode: "clone",
initialId: "filesystem-server-default",
initialConfig: stdioConfig,
existingIds: ["filesystem-server-default"],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("Clone server")).toBeInTheDocument();
const idInput = body.getByLabelText(/Server ID/i) as HTMLInputElement;
await expect(idInput.value).toBe("");
const cmdInput = body.getByLabelText(/Command/i) as HTMLInputElement;
await expect(cmdInput.value).toBe("npx");
},
};
export const EditSse: Story = {
args: {
mode: "edit",
initialId: "remote",
initialConfig: sseConfig,
existingIds: [],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByLabelText(/^URL/)).toBeInTheDocument();
await expect(body.getByLabelText(/Headers/i)).toBeInTheDocument();
},
};

View file

@ -0,0 +1,308 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { waitFor } from "@testing-library/react";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ServerConfigModal } from "./ServerConfigModal";
describe("ServerConfigModal", () => {
function base(overrides: Partial<{ existingIds: string[] }> = {}) {
return {
opened: true,
mode: "add" as const,
existingIds: overrides.existingIds ?? [],
onClose: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
};
}
it("does not render when opened is false", () => {
renderWithMantine(<ServerConfigModal {...base()} opened={false} />);
expect(screen.queryByText("Add server")).not.toBeInTheDocument();
});
it("renders the add title and stdio fields by default", () => {
renderWithMantine(<ServerConfigModal {...base()} />);
expect(screen.getByText("Add server")).toBeInTheDocument();
expect(screen.getByLabelText(/Server ID/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Command/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Arguments/i)).toBeInTheDocument();
});
it("rejects an id containing illegal characters", async () => {
const user = userEvent.setup();
renderWithMantine(<ServerConfigModal {...base()} />);
await user.type(screen.getByLabelText(/Server ID/i), "bad id!");
expect(
screen.getByText(/letters, numbers, hyphens, and underscores/i),
).toBeInTheDocument();
});
it("flags duplicate ids against existingIds", async () => {
const user = userEvent.setup();
renderWithMantine(
<ServerConfigModal {...base({ existingIds: ["alpha"] })} />,
);
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
expect(screen.getByText(/already exists/i)).toBeInTheDocument();
});
it("calls onSubmit with a valid stdio config", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
await user.type(screen.getByLabelText(/Command/i), "node");
await user.type(screen.getByLabelText(/Arguments/i), "x.js\n--port=3000");
await user.click(screen.getByRole("button", { name: /^Add$/ }));
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
expect(props.onSubmit).toHaveBeenCalledWith("alpha", {
type: "stdio",
command: "node",
args: ["x.js", "--port=3000"],
});
});
it("requires a command for stdio submission", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
await user.click(screen.getByRole("button", { name: /^Add$/ }));
expect(screen.getByRole("alert")).toHaveTextContent(/Command is required/i);
expect(props.onSubmit).not.toHaveBeenCalled();
});
it("rejects malformed env lines", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
await user.type(screen.getByLabelText(/Command/i), "node");
await user.type(screen.getByLabelText(/Environment/i), "BAD_LINE");
await user.click(screen.getByRole("button", { name: /^Add$/ }));
expect(screen.getByRole("alert")).toHaveTextContent(/Invalid env/i);
expect(props.onSubmit).not.toHaveBeenCalled();
});
// Mantine Select uses a portal-mounted dropdown that happy-dom doesn't
// open reliably; we exercise the sse rendering branch by seeding the form
// with an sse initialConfig (the same code path the Select onChange takes).
it("renders url + headers (and hides stdio fields) when transport is sse", () => {
renderWithMantine(
<ServerConfigModal
{...base()}
mode="edit"
initialId="remote"
initialConfig={{ type: "sse", url: "https://x.test/sse" }}
/>,
);
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument();
expect(screen.getByLabelText(/Headers/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Command/)).not.toBeInTheDocument();
});
it("submits an sse config with parsed headers", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(
<ServerConfigModal
{...props}
mode="edit"
initialId="remote"
initialConfig={{ type: "sse", url: "" }}
/>,
);
await user.type(screen.getByLabelText(/^URL/), "https://x.test/sse");
await user.type(
screen.getByLabelText(/Headers/i),
"Authorization: Bearer xxx",
);
await user.click(screen.getByRole("button", { name: /^Save$/ }));
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
expect(props.onSubmit).toHaveBeenCalledWith("remote", {
type: "sse",
url: "https://x.test/sse",
headers: { Authorization: "Bearer xxx" },
});
});
it("rejects malformed header lines on sse submission", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(
<ServerConfigModal
{...props}
mode="edit"
initialId="remote"
initialConfig={{ type: "sse", url: "https://x.test/sse" }}
/>,
);
await user.type(screen.getByLabelText(/Headers/i), "BAD_HEADER_LINE");
await user.click(screen.getByRole("button", { name: /^Save$/ }));
expect(screen.getByRole("alert")).toHaveTextContent(/Invalid header/i);
expect(props.onSubmit).not.toHaveBeenCalled();
});
it("submits a streamable-http config (with no headers)", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(
<ServerConfigModal
{...props}
mode="edit"
initialId="http-srv"
initialConfig={{ type: "streamable-http", url: "https://x.test/mcp" }}
/>,
);
await user.click(screen.getByRole("button", { name: /^Save$/ }));
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
expect(props.onSubmit).toHaveBeenCalledWith("http-srv", {
type: "streamable-http",
url: "https://x.test/mcp",
});
});
it("loads initial headers + url from a streamable-http config", () => {
renderWithMantine(
<ServerConfigModal
{...base()}
mode="edit"
initialId="http-srv"
initialConfig={{
type: "streamable-http",
url: "https://x.test/mcp",
headers: { "X-Trace": "abc" },
}}
/>,
);
expect(screen.getByLabelText(/^URL/)).toHaveValue("https://x.test/mcp");
expect(screen.getByLabelText(/Headers/i)).toHaveValue("X-Trace: abc");
});
it("rejects an empty URL on sse submission", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(
<ServerConfigModal
{...props}
mode="edit"
initialId="remote"
initialConfig={{ type: "sse", url: "" }}
/>,
);
await user.click(screen.getByRole("button", { name: /^Save$/ }));
expect(screen.getByRole("alert")).toHaveTextContent(/URL is required/i);
expect(props.onSubmit).not.toHaveBeenCalled();
});
it("pre-populates fields in edit mode", () => {
const initialConfig: MCPServerConfig = {
type: "stdio",
command: "node",
args: ["server.js"],
env: { DEBUG: "1" },
cwd: "/tmp/here",
};
renderWithMantine(
<ServerConfigModal
{...base()}
mode="edit"
initialId="alpha"
initialConfig={initialConfig}
/>,
);
expect(screen.getByText("Edit server")).toBeInTheDocument();
expect(screen.getByLabelText(/Server ID/i)).toHaveValue("alpha");
expect(screen.getByLabelText(/Command/i)).toHaveValue("node");
expect(screen.getByLabelText(/Arguments/i)).toHaveValue("server.js");
expect(screen.getByLabelText(/Environment/i)).toHaveValue("DEBUG=1");
expect(screen.getByLabelText(/Working directory/i)).toHaveValue(
"/tmp/here",
);
});
it("clears the id field in clone mode (everything else carries over)", () => {
const initialConfig: MCPServerConfig = {
type: "stdio",
command: "node",
};
renderWithMantine(
<ServerConfigModal
{...base()}
mode="clone"
initialId="alpha"
initialConfig={initialConfig}
/>,
);
expect(screen.getByText("Clone server")).toBeInTheDocument();
expect(screen.getByLabelText(/Server ID/i)).toHaveValue("");
expect(screen.getByLabelText(/Command/i)).toHaveValue("node");
});
it("shows the error message and stays open when onSubmit rejects", async () => {
const user = userEvent.setup();
const props = {
...base(),
onSubmit: vi.fn().mockRejectedValue(new Error("server full")),
};
renderWithMantine(<ServerConfigModal {...props} />);
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
await user.type(screen.getByLabelText(/Command/i), "node");
await user.click(screen.getByRole("button", { name: /^Add$/ }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/server full/);
});
expect(props.onClose).not.toHaveBeenCalled();
});
it("blocks submit and shows the id error message on bad-id submit click", async () => {
const user = userEvent.setup();
const props = base({ existingIds: ["alpha"] });
renderWithMantine(<ServerConfigModal {...props} />);
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
await user.type(screen.getByLabelText(/Command/i), "node");
await user.click(screen.getByRole("button", { name: /^Add$/ }));
expect(screen.getByRole("alert")).toHaveTextContent(/already exists/i);
expect(props.onSubmit).not.toHaveBeenCalled();
});
it("supports editing the working directory field", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
await user.type(screen.getByLabelText(/Command/i), "node");
await user.type(screen.getByLabelText(/Working directory/i), "/tmp/cwd");
await user.click(screen.getByRole("button", { name: /^Add$/ }));
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
expect(props.onSubmit).toHaveBeenCalledWith("alpha", {
type: "stdio",
command: "node",
cwd: "/tmp/cwd",
});
});
it("calls onClose when Cancel is clicked", async () => {
const user = userEvent.setup();
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
await user.click(screen.getByRole("button", { name: /Cancel/ }));
expect(props.onClose).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,438 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import type {
MCPServerConfig,
SseServerConfig,
StdioServerConfig,
StreamableHttpServerConfig,
} from "@inspector/core/mcp/types.js";
/** Allowed id pattern — mirrors validateStoreId in core/storage/store-io.ts */
const ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
export type ServerConfigModalMode = "add" | "edit" | "clone";
export interface ServerConfigModalProps {
opened: boolean;
mode: ServerConfigModalMode;
/** When editing, the existing id of the target server. */
initialId?: string;
/** When editing or cloning, the existing config to pre-populate. */
initialConfig?: MCPServerConfig;
/** Ids already in use — drives the uniqueness check (caller excludes the
* target id from this list when in 'edit' mode). */
existingIds: string[];
onClose: () => void;
onSubmit: (id: string, config: MCPServerConfig) => Promise<void> | void;
}
type TransportChoice = "stdio" | "sse" | "streamable-http";
interface FormState {
id: string;
transport: TransportChoice;
// stdio
command: string;
argsText: string;
envText: string;
cwd: string;
// sse / streamable-http
url: string;
headersText: string;
}
const SectionStack = Stack.withProps({ gap: "md" });
const FieldGrid = Stack.withProps({ gap: "sm" });
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
const MODE_TITLES: Record<ServerConfigModalMode, string> = {
add: "Add server",
edit: "Edit server",
clone: "Clone server",
};
function configToFormState(
initialId: string | undefined,
initialConfig: MCPServerConfig | undefined,
mode: ServerConfigModalMode,
): FormState {
const id = mode === "edit" ? (initialId ?? "") : "";
const transport: TransportChoice =
initialConfig?.type === undefined ? "stdio" : initialConfig.type;
if (!initialConfig) {
return {
id,
transport: "stdio",
command: "",
argsText: "",
envText: "",
cwd: "",
url: "",
headersText: "",
};
}
if (transport === "stdio") {
const c = initialConfig as StdioServerConfig;
return {
id,
transport,
command: c.command ?? "",
argsText: (c.args ?? []).join("\n"),
envText: Object.entries(c.env ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n"),
cwd: c.cwd ?? "",
url: "",
headersText: "",
};
}
// sse / streamable-http
const c = initialConfig as SseServerConfig | StreamableHttpServerConfig;
return {
id,
transport,
command: "",
argsText: "",
envText: "",
cwd: "",
url: c.url ?? "",
headersText: Object.entries(c.headers ?? {})
.map(([k, v]) => `${k}: ${v}`)
.join("\n"),
};
}
function parseArgs(raw: string): string[] {
return raw
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function parseEnv(raw: string):
| {
ok: true;
value: Record<string, string>;
}
| {
ok: false;
error: string;
} {
const out: Record<string, string> = {};
const lines = raw
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) {
const eq = line.indexOf("=");
if (eq <= 0) {
return { ok: false, error: `Invalid env line "${line}". Use KEY=VALUE.` };
}
const key = line.slice(0, eq).trim();
// env values preserve trailing whitespace — they're shell-style strings
// where spaces / tabs can be load-bearing; header values are HTTP-style
// and parseHeaders below trims them per RFC 7230 §3.2.4.
const value = line.slice(eq + 1);
if (!key)
return { ok: false, error: `Invalid env line "${line}". Empty key.` };
out[key] = value;
}
return { ok: true, value: out };
}
function parseHeaders(raw: string):
| {
ok: true;
value: Record<string, string>;
}
| {
ok: false;
error: string;
} {
const out: Record<string, string> = {};
const lines = raw
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) {
const colon = line.indexOf(":");
if (colon <= 0) {
return {
ok: false,
error: `Invalid header line "${line}". Use "Header-Name: value".`,
};
}
const key = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
if (!key) {
return { ok: false, error: `Invalid header line "${line}". Empty name.` };
}
out[key] = value;
}
return { ok: true, value: out };
}
export function ServerConfigModal({
opened,
mode,
initialId,
initialConfig,
existingIds,
onClose,
onSubmit,
}: ServerConfigModalProps) {
const initial = useMemo(
() => configToFormState(initialId, initialConfig, mode),
[initialId, initialConfig, mode],
);
const [form, setForm] = useState<FormState>(initial);
const [submitError, setSubmitError] = useState<string | undefined>(undefined);
const [submitting, setSubmitting] = useState<boolean>(false);
// Reset form whenever the modal opens with new inputs.
useEffect(() => {
if (opened) {
setForm(initial);
setSubmitError(undefined);
setSubmitting(false);
}
}, [opened, initial]);
const trimmedId = form.id.trim();
const idIsValid = ID_PATTERN.test(trimmedId);
const idIsDuplicate = trimmedId.length > 0 && existingIds.includes(trimmedId);
const idError = !trimmedId
? undefined
: !idIsValid
? "Use only letters, numbers, hyphens, and underscores."
: idIsDuplicate
? "A server with this id already exists."
: undefined;
function buildConfig():
| {
ok: true;
config: MCPServerConfig;
}
| {
ok: false;
error: string;
} {
if (form.transport === "stdio") {
if (!form.command.trim()) {
return { ok: false, error: "Command is required for stdio." };
}
const env = parseEnv(form.envText);
if (!env.ok) return env;
const config: StdioServerConfig = {
type: "stdio",
command: form.command.trim(),
};
const args = parseArgs(form.argsText);
if (args.length > 0) config.args = args;
if (Object.keys(env.value).length > 0) config.env = env.value;
const cwd = form.cwd.trim();
if (cwd) config.cwd = cwd;
return { ok: true, config };
}
if (!form.url.trim()) {
return { ok: false, error: "URL is required for sse / streamable-http." };
}
const headers = parseHeaders(form.headersText);
if (!headers.ok) return headers;
const base = { url: form.url.trim() };
const config: SseServerConfig | StreamableHttpServerConfig =
form.transport === "sse"
? { type: "sse", ...base }
: { type: "streamable-http", ...base };
if (Object.keys(headers.value).length > 0) config.headers = headers.value;
return { ok: true, config };
}
async function handleSubmit() {
setSubmitError(undefined);
if (!trimmedId) {
setSubmitError("Server id is required.");
return;
}
if (idError) {
setSubmitError(idError);
return;
}
const built = buildConfig();
if (!built.ok) {
setSubmitError(built.error);
return;
}
setSubmitting(true);
try {
await onSubmit(trimmedId, built.config);
onClose();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
}
const isStdio = form.transport === "stdio";
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
centered
title={MODE_TITLES[mode]}
>
<SectionStack>
<FieldGrid>
<TextInput
label="Server ID"
description="Used as the key in mcp.json. Letters, numbers, hyphens, underscores."
placeholder="my-server"
value={form.id}
onChange={(e) => {
// Capture before the functional updater closes over it — React
// nulls SyntheticEvent.currentTarget after the synchronous
// handler returns, so reading inside `setForm((f) => ...)`
// throws "Cannot read properties of null".
const next = e.currentTarget.value;
setForm((f) => ({ ...f, id: next }));
}}
error={idError}
data-autofocus
required
disabled={submitting}
/>
<Select
label="Transport"
data={[
{ value: "stdio", label: "stdio (local process)" },
{ value: "sse", label: "sse (Server-Sent Events)" },
{ value: "streamable-http", label: "streamable-http" },
]}
value={form.transport}
onChange={(value) =>
setForm((f) => ({
...f,
transport: (value ?? "stdio") as TransportChoice,
}))
}
allowDeselect={false}
disabled={submitting}
/>
{isStdio ? (
<>
<TextInput
label="Command"
placeholder="npx"
value={form.command}
onChange={(e) => {
const next = e.currentTarget.value;
setForm((f) => ({ ...f, command: next }));
}}
required
disabled={submitting}
/>
<Textarea
label="Arguments"
description="One argument per line."
placeholder={"-y\n@modelcontextprotocol/server-everything"}
value={form.argsText}
onChange={(e) => {
const next = e.currentTarget.value;
setForm((f) => ({ ...f, argsText: next }));
}}
autosize
minRows={3}
disabled={submitting}
/>
<Textarea
label="Environment"
description="KEY=VALUE per line."
placeholder="DEBUG=1"
value={form.envText}
onChange={(e) => {
const next = e.currentTarget.value;
setForm((f) => ({ ...f, envText: next }));
}}
autosize
minRows={2}
disabled={submitting}
/>
<TextInput
label="Working directory"
placeholder="(inherit)"
value={form.cwd}
onChange={(e) => {
const next = e.currentTarget.value;
setForm((f) => ({ ...f, cwd: next }));
}}
disabled={submitting}
/>
</>
) : (
<>
<TextInput
label="URL"
placeholder="https://example.com/mcp"
value={form.url}
onChange={(e) => {
const next = e.currentTarget.value;
setForm((f) => ({ ...f, url: next }));
}}
required
disabled={submitting}
/>
<Textarea
label="Headers"
description='"Header-Name: value" per line.'
placeholder="Authorization: Bearer xxxxx"
value={form.headersText}
onChange={(e) => {
const next = e.currentTarget.value;
setForm((f) => ({ ...f, headersText: next }));
}}
autosize
minRows={2}
disabled={submitting}
/>
</>
)}
</FieldGrid>
{submitError ? (
<Text c="red" size="sm" role="alert">
{submitError}
</Text>
) : null}
<Actions>
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
onClick={() => {
void handleSubmit();
}}
loading={submitting}
>
{mode === "edit" ? "Save" : "Add"}
</Button>
</Actions>
</SectionStack>
</Modal>
);
}

View file

@ -0,0 +1,88 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { useArgs } from "storybook/preview-api";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import {
ServerRemoveConfirmModal,
type ServerRemoveConfirmModalProps,
} from "./ServerRemoveConfirmModal";
function InteractiveRender(args: ServerRemoveConfirmModalProps) {
const [, updateArgs] = useArgs<ServerRemoveConfirmModalProps>();
return (
<AppShell>
<AppShell.Main>
<ServerRemoveConfirmModal
{...args}
onCancel={() => {
args.onCancel();
updateArgs({ opened: false });
}}
onConfirm={async () => {
await args.onConfirm();
updateArgs({ opened: false });
}}
/>
</AppShell.Main>
</AppShell>
);
}
const stdioTarget: ServerEntry = {
id: "filesystem-server-default",
name: "filesystem-server-default",
config: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
connection: { status: "disconnected" },
};
const httpTarget: ServerEntry = {
id: "remote",
name: "remote",
config: { type: "streamable-http", url: "https://example.com/mcp" },
connection: { status: "disconnected" },
};
const meta: Meta<typeof ServerRemoveConfirmModal> = {
title: "Groups/ServerRemoveConfirmModal",
component: ServerRemoveConfirmModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
onCancel: fn(),
onConfirm: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ServerRemoveConfirmModal>;
export const StdioServer: Story = {
args: { target: stdioTarget },
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("Remove server?")).toBeInTheDocument();
await expect(
body.getByText("filesystem-server-default"),
).toBeInTheDocument();
await expect(
body.getByText(/npx -y @modelcontextprotocol\/server-filesystem \/tmp/),
).toBeInTheDocument();
},
};
export const HttpServer: Story = {
args: { target: httpTarget },
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("remote")).toBeInTheDocument();
await expect(
body.getByText(/streamable-http · https:\/\/example\.com\/mcp/),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,131 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { waitFor } from "@testing-library/react";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ServerRemoveConfirmModal } from "./ServerRemoveConfirmModal";
const stdioTarget: ServerEntry = {
id: "alpha",
name: "alpha",
config: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-everything"],
},
connection: { status: "disconnected" },
};
const httpTarget: ServerEntry = {
id: "remote",
name: "remote",
config: { type: "streamable-http", url: "https://x.test/mcp" },
connection: { status: "disconnected" },
};
describe("ServerRemoveConfirmModal", () => {
it("does not render when opened is false", () => {
renderWithMantine(
<ServerRemoveConfirmModal
opened={false}
target={stdioTarget}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
);
expect(screen.queryByText(/Remove server\?/i)).not.toBeInTheDocument();
});
it("shows the target id and stdio command summary", () => {
renderWithMantine(
<ServerRemoveConfirmModal
opened
target={stdioTarget}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
);
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(
screen.getByText(/npx -y @modelcontextprotocol\/server-everything/),
).toBeInTheDocument();
});
it("shows the url for http transports", () => {
renderWithMantine(
<ServerRemoveConfirmModal
opened
target={httpTarget}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
);
expect(
screen.getByText(/streamable-http · https:\/\/x\.test\/mcp/),
).toBeInTheDocument();
});
it("calls onCancel when Cancel is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
renderWithMantine(
<ServerRemoveConfirmModal
opened
target={stdioTarget}
onCancel={onCancel}
onConfirm={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /Cancel/ }));
expect(onCancel).toHaveBeenCalledOnce();
});
it("calls onConfirm when Remove is clicked", async () => {
const user = userEvent.setup();
const onConfirm = vi.fn().mockResolvedValue(undefined);
renderWithMantine(
<ServerRemoveConfirmModal
opened
target={stdioTarget}
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
);
await user.click(screen.getByRole("button", { name: /^Remove$/ }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
});
it("surfaces an error message and stays open when onConfirm rejects", async () => {
const user = userEvent.setup();
const onConfirm = vi.fn().mockRejectedValue(new Error("disk full"));
const onCancel = vi.fn();
renderWithMantine(
<ServerRemoveConfirmModal
opened
target={stdioTarget}
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);
await user.click(screen.getByRole("button", { name: /^Remove$/ }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/disk full/);
});
expect(onCancel).not.toHaveBeenCalled();
});
it("is a no-op when Remove is clicked with no target", async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
renderWithMantine(
<ServerRemoveConfirmModal
opened
target={null}
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
);
await user.click(screen.getByRole("button", { name: /^Remove$/ }));
expect(onConfirm).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,103 @@
import { useState } from "react";
import { Button, Group, Modal, Paper, Stack, Text } from "@mantine/core";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
export interface ServerRemoveConfirmModalProps {
opened: boolean;
/** The server about to be removed; null when the modal is closed. */
target: ServerEntry | null;
onConfirm: () => Promise<void> | void;
onCancel: () => void;
}
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
const Summary = Paper.withProps({
p: "sm",
radius: "sm",
bg: "var(--inspector-surface-subtle)",
withBorder: true,
});
function summarize(config: ServerEntry["config"] | undefined): string {
if (!config) return "";
// StdioServerConfig has `type?: "stdio"` (optional), which means
// `config.type === "stdio"` doesn't narrow away the undefined-type stdio
// case. Discriminate on the unique field instead — stdio has command,
// sse/streamable-http have url.
if ("url" in config) return config.url;
return [config.command, ...(config.args ?? [])].join(" ");
}
export function ServerRemoveConfirmModal({
opened,
target,
onConfirm,
onCancel,
}: ServerRemoveConfirmModalProps) {
const [submitting, setSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>(undefined);
async function handleConfirm() {
if (!target) return;
setError(undefined);
setSubmitting(true);
try {
await onConfirm();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
}
return (
<Modal
opened={opened}
onClose={onCancel}
size="md"
centered
title="Remove server?"
>
<Stack gap="md">
<Text size="sm">
The entry will be removed from{" "}
<Text component="span" fw={600}>
~/.mcp-inspector/mcp.json
</Text>
. You can add it back at any time.
</Text>
{target ? (
<Summary>
<Stack gap={4}>
<Text size="sm" fw={600}>
{target.id}
</Text>
<Text size="xs" c="dimmed">
{target.config.type ?? "stdio"} · {summarize(target.config)}
</Text>
</Stack>
</Summary>
) : null}
{error ? (
<Text c="red" size="sm" role="alert">
{error}
</Text>
) : null}
<Actions>
<Button variant="default" onClick={onCancel} disabled={submitting}>
Cancel
</Button>
<Button
color="red"
onClick={() => {
void handleConfirm();
}}
loading={submitting}
>
Remove
</Button>
</Actions>
</Stack>
</Modal>
);
}

View file

@ -0,0 +1,195 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_SEED_CONFIG,
mcpConfigToServerEntries,
normalizeServerType,
serverEntriesToMcpConfig,
} from "@inspector/core/mcp/serverList.js";
import type { MCPConfig, ServerEntry } from "@inspector/core/mcp/types.js";
describe("normalizeServerType", () => {
it("defaults missing type to stdio", () => {
expect(normalizeServerType({ command: "node" })).toEqual({
type: "stdio",
command: "node",
});
});
it("rewrites 'http' to 'streamable-http'", () => {
expect(
normalizeServerType({ type: "http", url: "https://x.test" }),
).toEqual({
type: "streamable-http",
url: "https://x.test",
});
});
it("leaves sse / stdio / streamable-http alone", () => {
expect(
normalizeServerType({ type: "sse", url: "https://x.test" }),
).toMatchObject({ type: "sse" });
expect(
normalizeServerType({ type: "stdio", command: "node" }),
).toMatchObject({ type: "stdio" });
expect(
normalizeServerType({ type: "streamable-http", url: "https://x.test" }),
).toMatchObject({ type: "streamable-http" });
});
it("defaults an unknown string type to stdio (lenient on read)", () => {
expect(
normalizeServerType({ type: "websocket", command: "node" } as never),
).toMatchObject({ type: "stdio" });
expect(
normalizeServerType({ type: "", command: "node" } as never),
).toMatchObject({ type: "stdio" });
});
it("defaults a non-string type to stdio", () => {
expect(
normalizeServerType({ type: 42, command: "node" } as never),
).toMatchObject({ type: "stdio" });
expect(
normalizeServerType({ type: null, command: "node" } as never),
).toMatchObject({ type: "stdio" });
});
it("returns a fresh object (does not mutate input)", () => {
const input = { command: "node" };
const out = normalizeServerType(input);
expect(out).not.toBe(input);
expect(input).not.toHaveProperty("type");
});
});
describe("mcpConfigToServerEntries", () => {
it("uses the map key as both id and name", () => {
const cfg: MCPConfig = {
mcpServers: {
alpha: { type: "stdio", command: "node" },
},
};
const [entry] = mcpConfigToServerEntries(cfg);
expect(entry.id).toBe("alpha");
expect(entry.name).toBe("alpha");
});
it("initializes connection to disconnected", () => {
const [entry] = mcpConfigToServerEntries({
mcpServers: { alpha: { type: "stdio", command: "node" } },
});
expect(entry.connection).toEqual({ status: "disconnected" });
});
it("normalizes legacy 'http' and missing type", () => {
const cfg: MCPConfig = {
mcpServers: {
legacy: { command: "node" } as never,
httpish: { type: "http", url: "https://x.test" } as never,
},
};
const entries = mcpConfigToServerEntries(cfg);
expect(entries[0]?.config.type).toBe("stdio");
expect(entries[1]?.config.type).toBe("streamable-http");
});
it("preserves insertion order across entries", () => {
const cfg: MCPConfig = {
mcpServers: {
a: { type: "stdio", command: "1" },
b: { type: "stdio", command: "2" },
c: { type: "stdio", command: "3" },
},
};
expect(mcpConfigToServerEntries(cfg).map((e) => e.id)).toEqual([
"a",
"b",
"c",
]);
});
it("yields an empty list for an empty map", () => {
expect(mcpConfigToServerEntries({ mcpServers: {} })).toEqual([]);
});
});
describe("serverEntriesToMcpConfig", () => {
it("strips runtime-only fields (connection, info, name)", () => {
const entries: ServerEntry[] = [
{
id: "alpha",
name: "Alpha (pretty)",
config: { type: "stdio", command: "node" },
connection: { status: "connected" },
info: { name: "alpha-impl", version: "1.0.0" },
},
];
const cfg = serverEntriesToMcpConfig(entries);
expect(cfg).toEqual({
mcpServers: {
alpha: { type: "stdio", command: "node" },
},
});
});
it("round-trips through mcpConfigToServerEntries without data loss on config", () => {
const original: MCPConfig = {
mcpServers: {
alpha: { type: "stdio", command: "node", args: ["-y", "x"] },
beta: {
type: "sse",
url: "https://x.test",
headers: { Authorization: "Bearer y" },
},
},
};
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
expect(round).toEqual(original);
});
it("preserves insertion order on serialize", () => {
const entries: ServerEntry[] = [
{
id: "a",
name: "a",
config: { type: "stdio", command: "1" },
connection: { status: "disconnected" },
},
{
id: "b",
name: "b",
config: { type: "stdio", command: "2" },
connection: { status: "disconnected" },
},
];
expect(Object.keys(serverEntriesToMcpConfig(entries).mcpServers)).toEqual([
"a",
"b",
]);
});
});
describe("DEFAULT_SEED_CONFIG", () => {
it("contains the two canonical seed servers", () => {
expect(Object.keys(DEFAULT_SEED_CONFIG.mcpServers)).toEqual([
"filesystem-server-default",
"everything-server-default",
]);
});
it("uses stdio + npx for both seeds", () => {
for (const cfg of Object.values(DEFAULT_SEED_CONFIG.mcpServers)) {
expect(cfg.type).toBe("stdio");
if (cfg.type === "stdio") {
expect(cfg.command).toBe("npx");
}
}
});
it("scopes the filesystem server to /tmp by default", () => {
const fs = DEFAULT_SEED_CONFIG.mcpServers["filesystem-server-default"];
if (fs?.type === "stdio") {
expect(fs.args).toContain("/tmp");
}
});
});

View file

@ -26,7 +26,10 @@ describe("ManagedRequestorTasksState", () => {
let state: ManagedRequestorTasksState;
beforeEach(() => {
client = new FakeInspectorClient();
// Default to a server that advertises `tasks` so the existing flow tests
// exercise the live `listRequestorTasks` path; capability-absent tests
// below override this.
client = new FakeInspectorClient({ capabilities: { tasks: {} } });
state = new ManagedRequestorTasksState(client);
});
@ -46,6 +49,38 @@ describe("ManagedRequestorTasksState", () => {
expect(client.listRequestorTasks).not.toHaveBeenCalled();
});
it("refresh skips listRequestorTasks when the server doesn't advertise tasks capability", async () => {
// Regression: pre-fix we always called tasks/list on connect; servers
// that don't implement task tracking (the common case) replied with
// -32601 "Method not found" and the error surfaced in the console.
const taskless = new FakeInspectorClient({
capabilities: { tools: {}, prompts: {}, resources: {} },
});
taskless.setStatus("connected");
const tasklessState = new ManagedRequestorTasksState(taskless);
const result = await tasklessState.refresh();
expect(result).toEqual([]);
expect(taskless.listRequestorTasks).not.toHaveBeenCalled();
});
it("connect against a tasks-less server doesn't fire listRequestorTasks", async () => {
// The connect event runs refresh; the capability gate must also catch it
// there, not only the publicly-callable refresh().
const taskless = new FakeInspectorClient({
capabilities: { tools: {} },
});
taskless.setStatus("connected");
const tasklessState = new ManagedRequestorTasksState(taskless);
taskless.dispatchTypedEvent("connect");
// Yield once so the async refresh chained off connect runs.
await Promise.resolve();
await Promise.resolve();
expect(taskless.listRequestorTasks).not.toHaveBeenCalled();
expect(tasklessState.getTasks()).toEqual([]);
});
it("refresh fetches a single page and dispatches tasksChange", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });

View file

@ -20,7 +20,12 @@ describe("useManagedRequestorTasks", () => {
let state: ManagedRequestorTasksState;
beforeEach(() => {
client = new FakeInspectorClient({ status: "connected" });
// Capabilities include `tasks` so refresh() reaches the live
// listRequestorTasks path; the state manager gates on capability.
client = new FakeInspectorClient({
status: "connected",
capabilities: { tasks: {} },
});
state = new ManagedRequestorTasksState(client);
});

View file

@ -0,0 +1,294 @@
/**
* Tests for useServers — runs in happy-dom under the unit project, but
* exercises a real `createRemoteApp` Hono instance via `app.fetch` (no TCP,
* no port juggling) backed by a per-test tmp `mcp.json`. The route handlers
* and on-disk persistence are exercised end-to-end alongside the React hook.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { useServers } from "@inspector/core/react/useServers";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server";
import { DEFAULT_SEED_CONFIG } from "@inspector/core/mcp/serverList";
import type { MCPConfig } from "@inspector/core/mcp/types";
interface Harness {
fetchFn: typeof fetch;
configPath: string;
tempDir: string;
}
function setupHarness(): Harness {
const tempDir = mkdtempSync(join(tmpdir(), "inspector-useServers-"));
const configPath = join(tempDir, "mcp.json");
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: configPath,
initialConfig: { defaultEnvironment: {} },
});
// Wrap so the typing matches Web fetch — Hono's app.fetch expects a Request
// (not a URL string), so build one from string/URL inputs before dispatch.
const fetchFn: typeof fetch = async (input, init) => {
const req =
input instanceof Request
? input
: new Request(input as string | URL, init);
return app.fetch(req) as Promise<Response>;
};
return { fetchFn, configPath, tempDir };
}
function teardownHarness(h: Harness): void {
try {
rmSync(h.tempDir, { recursive: true });
} catch {
/* ignore */
}
}
function readConfig(path: string): MCPConfig {
return JSON.parse(readFileSync(path, "utf-8")) as MCPConfig;
}
describe("useServers", () => {
let h: Harness;
beforeEach(() => {
h = setupHarness();
});
afterEach(() => {
teardownHarness(h);
});
it("starts in loading state, then loads and converts the seed config", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
expect(result.current.loading).toBe(true);
expect(result.current.servers).toEqual([]);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const ids = result.current.servers.map((s) => s.id);
expect(ids).toEqual([
"filesystem-server-default",
"everything-server-default",
]);
// Map key is used as both id and name; connection initializes disconnected
for (const s of result.current.servers) {
expect(s.name).toBe(s.id);
expect(s.connection).toEqual({ status: "disconnected" });
}
expect(result.current.error).toBeUndefined();
});
it("addServer persists and refreshes the list", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.addServer("alpha", {
type: "stdio",
command: "node",
});
});
await waitFor(() => {
expect(result.current.servers.some((s) => s.id === "alpha")).toBe(true);
});
expect(readConfig(h.configPath).mcpServers.alpha).toEqual({
type: "stdio",
command: "node",
});
});
it("addServer throws on 409 (duplicate id)", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.addServer("alpha", {
type: "stdio",
command: "node",
});
});
await expect(
act(async () => {
await result.current.addServer("alpha", {
type: "stdio",
command: "other",
});
}),
).rejects.toThrow(/already exists/);
});
it("updateServer renames a key and updates config", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { alpha: { type: "stdio", command: "old" } },
}),
);
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.updateServer("alpha", "alpha-renamed", {
type: "stdio",
command: "new",
});
});
await waitFor(() => {
expect(result.current.servers.map((s) => s.id)).toEqual([
"alpha-renamed",
]);
});
const cfg = readConfig(h.configPath);
expect(cfg.mcpServers).not.toHaveProperty("alpha");
expect(cfg.mcpServers["alpha-renamed"]).toEqual({
type: "stdio",
command: "new",
});
});
it("removeServer drops the entry and refreshes", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
alpha: { type: "stdio", command: "node" },
beta: { type: "stdio", command: "node" },
},
}),
);
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() =>
expect(result.current.servers.map((s) => s.id)).toEqual([
"alpha",
"beta",
]),
);
await act(async () => {
await result.current.removeServer("alpha");
});
await waitFor(() => {
expect(result.current.servers.map((s) => s.id)).toEqual(["beta"]);
});
});
it("refresh() re-reads from disk when the file changes externally", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
// External edit (simulates the user editing mcp.json by hand)
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { hand: { type: "stdio", command: "hand-edited" } },
}),
);
await act(async () => {
await result.current.refresh();
});
expect(result.current.servers.map((s) => s.id)).toEqual(["hand"]);
});
it("captures the error message on fetch network failure", async () => {
const failingFetch = vi
.fn<typeof fetch>()
.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() =>
useServers({
baseUrl: "http://test.local",
fetchFn: failingFetch as typeof fetch,
}),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("network down");
expect(result.current.servers).toEqual([]);
});
it("captures the error message on HTTP 500 from the backend", async () => {
const failingFetch = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "disk full" }), {
status: 500,
headers: { "Content-Type": "application/json" },
}),
);
const { result } = renderHook(() =>
useServers({
baseUrl: "http://test.local",
fetchFn: failingFetch as typeof fetch,
}),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("disk full");
});
it("falls back to HTTP status when the error body has no `error` field", async () => {
const failingFetch = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("oops", { status: 502 }));
const { result } = renderHook(() =>
useServers({
baseUrl: "http://test.local",
fetchFn: failingFetch as typeof fetch,
}),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("HTTP 502");
});
it("sends the x-mcp-remote-auth header when authToken is provided", async () => {
const seenHeaders: Headers[] = [];
const sniffingFetch: typeof fetch = async (input, init) => {
seenHeaders.push(new Headers(init?.headers));
return h.fetchFn(input, init);
};
const { result } = renderHook(() =>
useServers({
baseUrl: "http://test.local",
authToken: "secret",
fetchFn: sniffingFetch,
}),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(seenHeaders[0]?.get("x-mcp-remote-auth")).toBe("Bearer secret");
});
it("uses DEFAULT_SEED_CONFIG keys on the first load (seed-write contract)", async () => {
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.servers.map((s) => s.id)).toEqual(
Object.keys(DEFAULT_SEED_CONFIG.mcpServers),
);
});
});

View file

@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { join } from "node:path";
import {
getDefaultMcpConfigPath,
getDefaultStorageDir,
} from "@inspector/core/storage/store-io.js";
describe("getDefaultMcpConfigPath", () => {
const origHome = process.env.HOME;
const origProfile = process.env.USERPROFILE;
beforeEach(() => {
delete process.env.HOME;
delete process.env.USERPROFILE;
});
afterEach(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = origProfile;
});
it("prefers HOME when set", () => {
process.env.HOME = "/home/example";
expect(getDefaultMcpConfigPath()).toBe(
join("/home/example", ".mcp-inspector", "mcp.json"),
);
});
it("falls back to USERPROFILE when HOME is unset (Windows)", () => {
process.env.USERPROFILE = "C:\\Users\\example";
expect(getDefaultMcpConfigPath()).toBe(
join("C:\\Users\\example", ".mcp-inspector", "mcp.json"),
);
});
it("falls back to '.' when neither env var is set", () => {
expect(getDefaultMcpConfigPath()).toBe(
join(".", ".mcp-inspector", "mcp.json"),
);
});
it("co-locates with getDefaultStorageDir under the same parent", () => {
process.env.HOME = "/home/example";
const cfg = getDefaultMcpConfigPath();
const storage = getDefaultStorageDir();
// mcp.json sits one level above the storage/ subdir
expect(cfg).toBe(join("/home/example", ".mcp-inspector", "mcp.json"));
expect(storage).toBe(join("/home/example", ".mcp-inspector", "storage"));
});
});

View file

@ -0,0 +1,153 @@
/**
* Regression test for the crash-on-startup race: when a stdio subprocess
* spawns successfully but exits before the browser opens the SSE events
* stream, the user should see the actual error (stderr + transport_error)
* — not a bare "Session not found" 404.
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { serve } from "@hono/node-server";
import type { ServerType } from "@hono/node-server";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
interface Harness {
baseUrl: string;
server: ServerType;
}
async function startServer(): Promise<Harness> {
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
initialConfig: { defaultEnvironment: {} },
});
return new Promise((resolve, reject) => {
const server = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info) => {
const port =
info && typeof info === "object" && "port" in info
? (info as { port: number }).port
: 0;
resolve({ baseUrl: `http://127.0.0.1:${port}`, server });
},
);
server.on("error", reject);
});
}
async function teardown(h: Harness): Promise<void> {
await new Promise<void>((resolve) => h.server.close(() => resolve()));
}
interface ParsedSseEvent {
type: string;
data: unknown;
}
/**
* Read a streaming Response body and parse Server-Sent Events. Stops when
* the server closes the stream (read returns done).
*
* The SSE `data:` field carries the full SessionEvent JSON (both `type` and
* `data`), per the writer in /api/mcp/events — we unwrap and surface just
* the inner `.data` as the event payload, so callers can read e.g.
* `event.data.message` for stdio_log without an extra hop.
*/
async function readSseEvents(res: Response): Promise<ParsedSseEvent[]> {
if (!res.body) return [];
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const events: ParsedSseEvent[] = [];
while (true) {
const { value, done } = await reader.read();
if (value) buffer += decoder.decode(value, { stream: true });
// SSE events terminate with a blank line — split on it.
let split: number;
while ((split = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
let type: string | undefined;
let dataLine: string | undefined;
for (const line of block.split("\n")) {
if (line.startsWith("event:"))
type = line.slice("event:".length).trim();
else if (line.startsWith("data:"))
dataLine = line.slice("data:".length).trim();
}
if (type && dataLine) {
const parsed = JSON.parse(dataLine) as { data?: unknown };
events.push({ type, data: parsed.data });
}
}
if (done) break;
}
return events;
}
describe("crash-on-startup error surfacing", () => {
let h: Harness;
beforeEach(async () => {
h = await startServer();
});
afterEach(async () => {
await teardown(h);
});
it("surfaces stderr + transport_error when the subprocess exits before the events stream opens", async () => {
// node -e writes to stderr then exits non-zero. The transport itself
// spawns fine (so POST /api/mcp/connect returns 200), but the process
// is dead by the time the browser opens /api/mcp/events. Pre-fix, the
// session was deleted in transport.onclose and the events endpoint
// returned a bare 404; now it returns an SSE stream that drains the
// queued stderr + transport_error events and closes.
const config: MCPServerConfig = {
type: "stdio",
command: process.execPath, // current `node` binary
args: [
"-e",
"process.stderr.write('boom from script\\n'); process.exit(1);",
],
};
const connectRes = await fetch(`${h.baseUrl}/api/mcp/connect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
});
expect(connectRes.status).toBe(200);
const { sessionId } = (await connectRes.json()) as { sessionId: string };
expect(typeof sessionId).toBe("string");
// Give the subprocess time to die before opening the events stream.
await new Promise((resolve) => setTimeout(resolve, 200));
const eventsRes = await fetch(
`${h.baseUrl}/api/mcp/events?sessionId=${sessionId}`,
);
expect(eventsRes.status).toBe(200);
expect(eventsRes.headers.get("content-type")).toMatch(/event-stream/);
const events = await readSseEvents(eventsRes);
// Stderr event for "boom from script" plus a transport_error closing
// the stream — the user now sees the real reason instead of "Session
// not found".
const stderrEvents = events.filter((e) => e.type === "stdio_log");
expect(stderrEvents.length).toBeGreaterThan(0);
const stderrText = stderrEvents
.map((e) => (e.data as { message?: string } | undefined)?.message ?? "")
.join("\n");
expect(stderrText).toMatch(/boom from script/);
const transportError = events.find((e) => e.type === "transport_error");
expect(transportError).toBeDefined();
const err = transportError?.data as { error: string; code: number };
expect(err.error).toMatch(/Transport closed/);
expect(err.code).toBe(-32000);
});
});

View file

@ -0,0 +1,78 @@
/**
* Unit-level tests for RemoteSession's event queue + transport-death wiring.
*
* Lives under integration/ because RemoteSession imports SDK types that
* pull in node-only modules at runtime; the file is otherwise pure (no I/O,
* no network) so the integration runner is comfortable for it.
*/
import { describe, it, expect } from "vitest";
import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js";
describe("RemoteSession", () => {
it("queues events before a consumer attaches and drains them on attach", () => {
const session = new RemoteSession("s1");
session.onStderr({ timestamp: new Date(), message: "line one" });
session.onStderr({ timestamp: new Date(), message: "line two" });
const received: { type: string }[] = [];
session.setEventConsumer((event) => {
received.push({ type: event.type });
});
expect(received.map((e) => e.type)).toEqual(["stdio_log", "stdio_log"]);
});
it("queues the transport_error event when markTransportDead fires before a consumer attaches", () => {
// Regression: the previous behavior dropped the event when no consumer
// was attached, so a process that crashed during startup vanished into
// a bare "Session not found" 404 on the next /api/mcp/events poll.
const session = new RemoteSession("s2");
session.onStderr({
timestamp: new Date(),
message: "Error: Cannot find module 'bogus.js'",
});
session.markTransportDead("Transport closed - process may have exited");
const received: { type: string; data: unknown }[] = [];
session.setEventConsumer((event) => {
received.push({ type: event.type, data: event.data });
});
expect(received.map((e) => e.type)).toEqual([
"stdio_log",
"transport_error",
]);
const err = received[1]?.data as { error: string; code: number };
expect(err.error).toMatch(/Transport closed/);
expect(err.code).toBe(-32000);
});
it("delivers transport_error live when a consumer is already attached", () => {
const session = new RemoteSession("s3");
const received: { type: string }[] = [];
session.setEventConsumer((event) => {
received.push({ type: event.type });
});
session.markTransportDead("transport closed");
expect(received.map((e) => e.type)).toEqual(["transport_error"]);
});
it("isTransportDead + getTransportError reflect the marked state", () => {
const session = new RemoteSession("s4");
expect(session.isTransportDead()).toBe(false);
expect(session.getTransportError()).toBeNull();
session.markTransportDead("boom");
expect(session.isTransportDead()).toBe(true);
expect(session.getTransportError()).toBe("boom");
});
it("clearEventConsumer signals cleanup-needed when the transport is dead", () => {
const session = new RemoteSession("s5");
session.setEventConsumer(() => {});
expect(session.clearEventConsumer()).toBe(false);
session.setEventConsumer(() => {});
session.markTransportDead("boom");
expect(session.clearEventConsumer()).toBe(true);
});
});

View file

@ -0,0 +1,439 @@
/**
* Integration tests for /api/servers + /api/servers/:id routes.
* Spins up createRemoteApp against a per-test tmp mcpConfigPath and
* exercises the routes via real HTTP.
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import {
mkdtempSync,
readFileSync,
rmSync,
existsSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serve } from "@hono/node-server";
import type { ServerType } from "@hono/node-server";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import { DEFAULT_SEED_CONFIG } from "@inspector/core/mcp/serverList.js";
import type { MCPConfig } from "@inspector/core/mcp/types.js";
interface Harness {
baseUrl: string;
server: ServerType;
configPath: string;
tempDir: string;
}
async function startServer(configPath: string): Promise<{
baseUrl: string;
server: ServerType;
}> {
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: configPath,
initialConfig: { defaultEnvironment: {} },
});
return new Promise((resolve, reject) => {
const server = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info) => {
const port =
info && typeof info === "object" && "port" in info
? (info as { port: number }).port
: 0;
resolve({ baseUrl: `http://127.0.0.1:${port}`, server });
},
);
server.on("error", reject);
});
}
async function setup(): Promise<Harness> {
const tempDir = mkdtempSync(join(tmpdir(), "inspector-servers-route-"));
const configPath = join(tempDir, "mcp.json");
const { baseUrl, server } = await startServer(configPath);
return { baseUrl, server, configPath, tempDir };
}
async function teardown(h: Harness): Promise<void> {
await new Promise<void>((resolve) => h.server.close(() => resolve()));
try {
rmSync(h.tempDir, { recursive: true });
} catch {
/* ignore */
}
}
function readConfig(path: string): MCPConfig {
return JSON.parse(readFileSync(path, "utf-8")) as MCPConfig;
}
describe("/api/servers routes", () => {
let h: Harness;
beforeEach(async () => {
h = await setup();
});
afterEach(async () => {
await teardown(h);
});
describe("GET /api/servers", () => {
it("writes the seed config and returns it on first read (file absent)", async () => {
expect(existsSync(h.configPath)).toBe(false);
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const body = (await res.json()) as MCPConfig;
expect(body).toEqual(DEFAULT_SEED_CONFIG);
// File was created with the same content
expect(existsSync(h.configPath)).toBe(true);
expect(readConfig(h.configPath)).toEqual(DEFAULT_SEED_CONFIG);
});
it("returns the existing file content when present (no overwrite)", async () => {
const custom: MCPConfig = {
mcpServers: {
custom: { type: "stdio", command: "node", args: ["x.js"] },
},
};
writeFileSync(h.configPath, JSON.stringify(custom, null, 2));
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(custom);
// Untouched on disk
expect(readConfig(h.configPath)).toEqual(custom);
});
it("normalizes legacy 'http' and missing type on read", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
legacy: { command: "node" },
httpish: { type: "http", url: "https://x.test" },
},
}),
);
const res = await fetch(`${h.baseUrl}/api/servers`);
const body = (await res.json()) as MCPConfig;
expect(body.mcpServers.legacy?.type).toBe("stdio");
expect(body.mcpServers.httpish?.type).toBe("streamable-http");
});
it("treats a valid-JSON file without `mcpServers` as empty", async () => {
writeFileSync(h.configPath, JSON.stringify({ unrelated: 1 }));
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ mcpServers: {} });
});
it("surfaces a 500 (not silent empty) on invalid-JSON contents", async () => {
// Surfacing corruption rather than silently presenting "no servers" —
// the next POST/PUT/DELETE would otherwise read empty and clobber the
// user's broken-but-recoverable file.
writeFileSync(h.configPath, "not json {");
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(500);
const body = (await res.json()) as { error?: string };
expect(body.error).toMatch(/Failed to read server list/i);
});
});
describe("POST /api/servers", () => {
it("adds a new server and persists to disk", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "alpha",
config: { type: "stdio", command: "node" },
}),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(readConfig(h.configPath).mcpServers.alpha).toEqual({
type: "stdio",
command: "node",
});
});
it("returns 409 when the id already exists", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { alpha: { type: "stdio", command: "node" } },
}),
);
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "alpha",
config: { type: "stdio", command: "other" },
}),
});
expect(res.status).toBe(409);
});
it("rejects an id with path-traversal characters", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "../escape",
config: { type: "stdio", command: "node" },
}),
});
expect(res.status).toBe(400);
});
it("rejects a missing or non-object config", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: "alpha" }),
});
expect(res.status).toBe(400);
});
it("rejects malformed JSON body", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
});
it("returns 500 when the existing file is invalid JSON (matches GET semantics)", async () => {
writeFileSync(h.configPath, "not json {");
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "alpha",
config: { type: "stdio", command: "node" },
}),
});
expect(res.status).toBe(500);
});
it("normalizes the incoming config (http → streamable-http)", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "httpish",
config: { type: "http", url: "https://x.test" },
}),
});
expect(res.status).toBe(200);
expect(readConfig(h.configPath).mcpServers.httpish?.type).toBe(
"streamable-http",
);
});
});
describe("PUT /api/servers/:id", () => {
beforeEach(() => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
alpha: { type: "stdio", command: "old" },
beta: { type: "stdio", command: "beta-cmd" },
},
}),
);
});
it("updates config in place without renaming", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: { type: "stdio", command: "new" },
}),
});
expect(res.status).toBe(200);
const cfg = readConfig(h.configPath);
expect(cfg.mcpServers.alpha).toEqual({ type: "stdio", command: "new" });
// Key order preserved
expect(Object.keys(cfg.mcpServers)).toEqual(["alpha", "beta"]);
});
it("renames the key when id is supplied and different", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "alpha-renamed",
config: { type: "stdio", command: "new" },
}),
});
expect(res.status).toBe(200);
const cfg = readConfig(h.configPath);
expect(cfg.mcpServers).not.toHaveProperty("alpha");
expect(cfg.mcpServers["alpha-renamed"]).toEqual({
type: "stdio",
command: "new",
});
// New key replaces the original in its slot, beta stays after
expect(Object.keys(cfg.mcpServers)).toEqual(["alpha-renamed", "beta"]);
});
it("returns 404 when the original id does not exist", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/nonexistent`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: { type: "stdio", command: "x" },
}),
});
expect(res.status).toBe(404);
});
it("returns 409 when renaming to a key that already exists", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "beta",
config: { type: "stdio", command: "x" },
}),
});
expect(res.status).toBe(409);
});
it("rejects invalid original id", async () => {
// dots fail validateStoreId; using `..` directly would be collapsed by
// URL normalization before Hono routes the request.
const res = await fetch(`${h.baseUrl}/api/servers/bad.id`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: { type: "stdio", command: "x" },
}),
});
expect(res.status).toBe(400);
});
it("rejects an invalid new id", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "../escape",
config: { type: "stdio", command: "x" },
}),
});
expect(res.status).toBe(400);
});
it("rejects a missing config", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: "alpha" }),
});
expect(res.status).toBe(400);
});
it("rejects malformed JSON body", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
});
});
describe("concurrent mutations", () => {
it("does not lose updates when many adds fire in parallel (write-lock)", async () => {
// Without the in-process mutex, concurrent POSTs would all read the
// empty baseline and the last writer would clobber everyone else's
// entry. With the mutex, every entry should land on disk.
const ids = Array.from({ length: 25 }, (_, i) => `concurrent-${i}`);
const responses = await Promise.all(
ids.map((id) =>
fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id,
config: { type: "stdio", command: `cmd-${id}` },
}),
}),
),
);
for (const res of responses) {
expect(res.status).toBe(200);
}
const cfg = readConfig(h.configPath);
expect(Object.keys(cfg.mcpServers).sort()).toEqual([...ids].sort());
});
});
describe("DELETE /api/servers/:id", () => {
beforeEach(() => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
alpha: { type: "stdio", command: "node" },
beta: { type: "stdio", command: "node" },
},
}),
);
});
it("removes the entry and persists", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
method: "DELETE",
});
expect(res.status).toBe(200);
const cfg = readConfig(h.configPath);
expect(cfg.mcpServers).not.toHaveProperty("alpha");
expect(cfg.mcpServers).toHaveProperty("beta");
});
it("is idempotent when the id is not present", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/nonexistent`, {
method: "DELETE",
});
expect(res.status).toBe(200);
// beta untouched
expect(readConfig(h.configPath).mcpServers).toHaveProperty("beta");
});
it("is a 200 no-op when the file does not exist yet", async () => {
rmSync(h.configPath);
const res = await fetch(`${h.baseUrl}/api/servers/anything`, {
method: "DELETE",
});
expect(res.status).toBe(200);
});
it("rejects an invalid id", async () => {
const res = await fetch(`${h.baseUrl}/api/servers/bad.id`, {
method: "DELETE",
});
expect(res.status).toBe(400);
});
});
});

View file

@ -3,11 +3,11 @@ import { resolve } from "path";
import type {
MCPConfig,
MCPServerConfig,
ServerType,
StdioServerConfig,
SseServerConfig,
StreamableHttpServerConfig,
} from "../types.js";
import { normalizeServerType } from "../serverList.js";
/**
* Options object passed to resolveServerConfigs by runners (parsed from argv).
@ -74,23 +74,6 @@ export function parseHeaderPair(
return { ...previous, [key]: val };
}
/**
* Normalizes server type: missing → "stdio", "http" → "streamable-http".
* Returns a new object; input may be parsed JSON with type omitted or "http".
*/
function normalizeServerType(
config: Record<string, unknown> & { type?: string },
): MCPServerConfig {
const type = config.type;
const normalizedType: ServerType =
type === undefined
? "stdio"
: type === "http"
? "streamable-http"
: (type as ServerType);
return { ...config, type: normalizedType } as MCPServerConfig;
}
/**
* Loads and validates an MCP servers configuration file.
* Checks file existence before reading. Normalizes each server's type

View file

@ -46,16 +46,18 @@ export class RemoteSession {
markTransportDead(error: string): void {
this.transportDead = true;
this.transportError = error;
// Send error event if client is connected
if (this.eventConsumer) {
this.pushEvent({
type: "transport_error",
data: {
error,
code: -32000, // MCP error code for connection closed
},
});
}
// Always push the transport_error event — pushEvent queues it if no
// consumer is attached yet, so a process that crashes during startup
// (between POST /api/mcp/connect returning 200 and the browser opening
// /api/mcp/events) still surfaces its error to the eventual consumer
// instead of vanishing.
this.pushEvent({
type: "transport_error",
data: {
error,
code: -32000, // MCP error code for connection closed
},
});
}
isTransportDead(): boolean {

View file

@ -1,12 +1,14 @@
/**
* Hono-based remote server for MCP transports.
* Hosts /api/config, /api/mcp/connect, send, events, disconnect, /api/fetch, /api/log, /api/storage/:storeId.
* Hosts /api/config, /api/mcp/connect, send, events, disconnect, /api/fetch, /api/log,
* /api/storage/:storeId, /api/servers (+ /api/servers/:id).
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import type pino from "pino";
import {
getDefaultStorageDir,
getDefaultMcpConfigPath,
getStoreFilePath,
validateStoreId,
readStoreFile,
@ -21,7 +23,11 @@ import type { Context, Env, Next } from "hono";
import { streamSSE } from "hono/streaming";
import { createTransportNode } from "../../node/transport.js";
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
import type { MCPServerConfig } from "../../types.js";
import type { MCPConfig, MCPServerConfig } from "../../types.js";
import {
DEFAULT_SEED_CONFIG,
normalizeServerType,
} from "../../serverList.js";
import { RemoteSession } from "./remote-session.js";
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
import { API_SERVER_ENV_VARS } from "../constants.js";
@ -59,6 +65,9 @@ export interface RemoteServerOptions {
/** Optional storage directory for /api/storage/:storeId. Default: ~/.mcp-inspector/storage */
storageDir?: string;
/** Optional path for the user's server list file (/api/servers). Default: ~/.mcp-inspector/mcp.json */
mcpConfigPath?: string;
/** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */
sandboxUrl?: string;
@ -242,6 +251,7 @@ export function createRemoteApp(
const sessions = new Map<string, RemoteSession>();
const { logger: fileLogger, allowedOrigins } = options;
const storageDir = options.storageDir ?? getDefaultStorageDir();
const mcpConfigPath = options.mcpConfigPath ?? getDefaultMcpConfigPath();
// Apply origin validation middleware first (before auth)
// This prevents DNS rebinding attacks by validating Origin header
@ -319,9 +329,21 @@ export function createRemoteApp(
const errorMsg =
transportError || "Transport closed - process may have exited";
session.markTransportDead(errorMsg);
// If no client connected, can cleanup immediately
// If no client connected yet, the client may still be about to
// open /api/mcp/events for this session — common path when the
// subprocess fails during startup, after we've already returned
// 200 with the sessionId. Hold the session (with the queued
// stderr + transport_error event) for a grace window so the
// events endpoint can drain them and surface a real error to
// the user. The endpoint cleans up on stream close; this TTL
// sweeps sessions whose client never connects at all.
if (!session.hasEventConsumer()) {
sessions.delete(sessionId);
setTimeout(() => {
const stale = sessions.get(sessionId);
if (stale && !stale.hasEventConsumer()) {
sessions.delete(sessionId);
}
}, 30_000);
}
} else {
// Session not created yet - failed during start
@ -423,6 +445,19 @@ export function createRemoteApp(
});
});
// Crash-on-startup path: if the subprocess died between POST
// /api/mcp/connect (which returned 200) and this GET, the session is
// alive but the transport is dead. setEventConsumer above just
// drained the queued stderr + the transport_error event. Yield once
// so the writeSSE writes flush, then return — closing the stream and
// surfacing the real error instead of a bare 404 / silent hang.
if (session.isTransportDead()) {
await new Promise((resolve) => setTimeout(resolve, 0));
session.clearEventConsumer();
sessions.delete(sessionId);
return;
}
stream.onAbort(() => {
// Client disconnected - clear event consumer
const shouldCleanup = session.clearEventConsumer();
@ -592,5 +627,182 @@ export function createRemoteApp(
}
});
// --- /api/servers (server list backed by mcp.json) ---
// Defensive normalize so editor-edited files with type:"http" or missing
// type round-trip into the canonical form on every read.
const normalizeMcpServers = (raw: unknown): Record<string, MCPServerConfig> => {
if (!raw || typeof raw !== "object") return {};
const out: Record<string, MCPServerConfig> = {};
for (const [id, val] of Object.entries(raw as Record<string, unknown>)) {
if (!val || typeof val !== "object") continue;
out[id] = normalizeServerType(
val as Record<string, unknown> & { type?: string },
);
}
return out;
};
// In-process serialization for the read-modify-write flow on the
// mutating routes (POST/PUT/DELETE). `atomically` guarantees torn-write
// safety on the file itself, but two concurrent requests can both read the
// same baseline and the second write clobbers the first. Single-user local
// dev tool, so this is a guardrail rather than a hot-path concern, but it
// avoids a real lost-update once file watching (#1345) or any remote/
// multi-client usage lands.
let writeQueue: Promise<void> = Promise.resolve();
const withWriteLock = async <T>(fn: () => Promise<T>): Promise<T> => {
const prev = writeQueue;
let release: () => void = () => {};
writeQueue = new Promise<void>((r) => {
release = r;
});
try {
await prev;
return await fn();
} finally {
release();
}
};
// Load current config from disk. ENOENT → empty. A valid-JSON file without
// `mcpServers` is treated as empty (the user may have deliberately wiped it
// or a future field arrived above the key). Invalid JSON surfaces a 500
// from the route's outer try — we'd rather flag corruption than silently
// present "no servers" and let the next write clobber the broken file.
// The seed-write on first GET happens in the route, not here, so mutating
// routes don't accidentally trigger it.
const readMcpConfig = async (): Promise<MCPConfig> => {
const raw = await readStoreFile(mcpConfigPath);
if (raw === null) return { mcpServers: {} };
const parsed = parseStore(raw) as { mcpServers?: unknown } | null;
return { mcpServers: normalizeMcpServers(parsed?.mcpServers) };
};
app.get("/api/servers", async (c) => {
try {
const raw = await readStoreFile(mcpConfigPath);
if (raw === null) {
await writeStoreFile(mcpConfigPath, serializeStore(DEFAULT_SEED_CONFIG));
return c.json(DEFAULT_SEED_CONFIG);
}
const parsed = parseStore(raw) as { mcpServers?: unknown } | null;
return c.json({ mcpServers: normalizeMcpServers(parsed?.mcpServers) });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return c.json({ error: `Failed to read server list: ${msg}` }, 500);
}
});
app.post("/api/servers", async (c) => {
let body: { id?: unknown; config?: unknown };
try {
body = (await c.req.json()) as { id?: unknown; config?: unknown };
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
if (typeof body.id !== "string" || !validateStoreId(body.id)) {
return c.json(
{
error:
"Invalid id: must be non-empty and contain only alphanumeric, hyphen, or underscore",
},
400,
);
}
if (!body.config || typeof body.config !== "object") {
return c.json({ error: "Missing or invalid config" }, 400);
}
const id = body.id;
try {
return await withWriteLock(async () => {
const current = await readMcpConfig();
if (id in current.mcpServers) {
return c.json({ error: `Server '${id}' already exists` }, 409);
}
current.mcpServers[id] = normalizeServerType(
body.config as Record<string, unknown> & { type?: string },
);
await writeStoreFile(mcpConfigPath, serializeStore(current));
return c.json({ ok: true });
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return c.json({ error: `Failed to add server: ${msg}` }, 500);
}
});
app.put("/api/servers/:id", async (c) => {
const originalId = c.req.param("id");
if (!originalId || !validateStoreId(originalId)) {
return c.json({ error: "Invalid id" }, 400);
}
let body: { id?: unknown; config?: unknown };
try {
body = (await c.req.json()) as { id?: unknown; config?: unknown };
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
if (!body.config || typeof body.config !== "object") {
return c.json({ error: "Missing or invalid config" }, 400);
}
const newId = typeof body.id === "string" ? body.id : originalId;
if (!validateStoreId(newId)) {
return c.json({ error: "Invalid new id" }, 400);
}
try {
return await withWriteLock(async () => {
const current = await readMcpConfig();
if (!(originalId in current.mcpServers)) {
return c.json({ error: `Server '${originalId}' not found` }, 404);
}
if (newId !== originalId && newId in current.mcpServers) {
return c.json({ error: `Server '${newId}' already exists` }, 409);
}
// Rebuild preserving insertion order; replace the original key in place
// so the file diff stays minimal when not renaming.
const next: MCPConfig = { mcpServers: {} };
for (const [key, val] of Object.entries(current.mcpServers)) {
if (key === originalId) {
next.mcpServers[newId] = normalizeServerType(
body.config as Record<string, unknown> & { type?: string },
);
} else {
next.mcpServers[key] = val;
}
}
await writeStoreFile(mcpConfigPath, serializeStore(next));
return c.json({ ok: true });
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return c.json({ error: `Failed to update server: ${msg}` }, 500);
}
});
app.delete("/api/servers/:id", async (c) => {
const id = c.req.param("id");
if (!id || !validateStoreId(id)) {
return c.json({ error: "Invalid id" }, 400);
}
try {
return await withWriteLock(async () => {
const current = await readMcpConfig();
if (!(id in current.mcpServers)) {
return c.json({ ok: true });
}
delete current.mcpServers[id];
await writeStoreFile(mcpConfigPath, serializeStore(current));
return c.json({ ok: true });
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return c.json({ error: `Failed to delete server: ${msg}` }, 500);
}
});
return { app, authToken };
}

100
core/mcp/serverList.ts Normal file
View file

@ -0,0 +1,100 @@
/**
* Pure converters between the on-disk `mcp.json` shape (`MCPConfig`) and the
* in-memory list of `ServerEntry` records the UI consumes. No I/O, no Node
* deps — safe to import from the browser side of core/ as well as the
* remote-server route handlers.
*/
import type {
MCPConfig,
MCPServerConfig,
ServerEntry,
ServerType,
} from "./types.js";
// The full set of valid `type` discriminator values, used to reject anything
// else read off disk so unknown strings can't propagate to narrowing sites.
const VALID_SERVER_TYPES: ReadonlySet<ServerType> = new Set([
"stdio",
"sse",
"streamable-http",
]);
/**
* Normalizes server type:
* - missing / unknown / non-string → "stdio" (matches Claude Desktop's default)
* - "http" → "streamable-http" (legacy alias)
* - valid ServerType → passed through unchanged
*
* Lives here (rather than in node/config.ts) so the file stays Node-free
* and the same normalization is applied by every consumer of `mcp.json`.
* The "unknown → stdio" branch keeps a hand-edited file with `"type":"websocket"`
* or `"type": 42` from leaking through `as ServerType` casts into narrowing
* sites that would then fall through in surprising ways.
*/
export function normalizeServerType(
config: Record<string, unknown> & { type?: unknown },
): MCPServerConfig {
const type = config.type;
let normalizedType: ServerType;
if (typeof type !== "string") {
normalizedType = "stdio";
} else if (type === "http") {
normalizedType = "streamable-http";
} else if (VALID_SERVER_TYPES.has(type as ServerType)) {
normalizedType = type as ServerType;
} else {
normalizedType = "stdio";
}
return { ...config, type: normalizedType } as MCPServerConfig;
}
/**
* Convert the on-disk `MCPConfig` into the `ServerEntry[]` the Servers screen
* consumes. Map key becomes both `id` and `name`. Connection state initializes
* to `disconnected` — the React layer drives it from there.
*/
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
return Object.entries(config.mcpServers).map(([id, raw]) => ({
id,
name: id,
config: normalizeServerType(
raw as unknown as Record<string, unknown> & { type?: string },
),
connection: { status: "disconnected" },
}));
}
/**
* Convert `ServerEntry[]` back into `MCPConfig` for serialization. Strips
* runtime-only fields (connection, info, name) — only id and config make it
* to disk so the file stays a clean canonical `mcp.json`.
*/
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig {
const mcpServers: Record<string, MCPServerConfig> = {};
for (const entry of entries) {
mcpServers[entry.id] = entry.config;
}
return { mcpServers };
}
/**
* Default seeds written to `~/.mcp-inspector/mcp.json` on first launch when
* the file is absent. Picked to cover the two shapes a developer reaches for
* first: a real filesystem scoped to /tmp, and the canonical "everything"
* reference server.
*/
export const DEFAULT_SEED_CONFIG: MCPConfig = {
mcpServers: {
"filesystem-server-default": {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
"everything-server-default": {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-everything"],
},
},
};

Some files were not shown because too many files have changed in this diff Show more