inspector/core/mcp/node/config.ts
Cliff Hall db1916cd34
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>
2026-05-23 14:26:23 -04:00

340 lines
11 KiB
TypeScript

import { existsSync, readFileSync } from "fs";
import { resolve } from "path";
import type {
MCPConfig,
MCPServerConfig,
StdioServerConfig,
SseServerConfig,
StreamableHttpServerConfig,
} from "../types.js";
import { normalizeServerType } from "../serverList.js";
/**
* Options object passed to resolveServerConfigs by runners (parsed from argv).
* Core exports this type so runners can type the subset they pass in.
*/
export interface ServerConfigOptions {
configPath?: string;
serverName?: string;
/** Command + args for stdio, or [url] for SSE/HTTP. Positional / args after -- */
target?: string[];
transport?: "stdio" | "sse" | "http";
serverUrl?: string;
cwd?: string;
env?: Record<string, string>;
headers?: Record<string, string>;
}
/**
* Parse KEY=VALUE into a record. Used as Commander option coerce/accumulator for -e.
* Pure function; no Commander dependency.
*/
export function parseKeyValuePair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const parts = value.split("=");
const key = parts[0] ?? "";
const val = parts.slice(1).join("=");
if (!key || val === undefined || val === "") {
throw new Error(
`Invalid parameter format: ${value}. Use key=value format.`,
);
}
return { ...previous, [key]: val };
}
/**
* Parse "HeaderName: Value" into a record. Used as Commander option coerce/accumulator for --header.
* Pure function; no Commander dependency.
*/
export function parseHeaderPair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const colonIndex = value.indexOf(":");
if (colonIndex === -1) {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
const key = value.slice(0, colonIndex).trim();
const val = value.slice(colonIndex + 1).trim();
if (key === "" || val === "") {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
return { ...previous, [key]: val };
}
/**
* Loads and validates an MCP servers configuration file.
* Checks file existence before reading. Normalizes each server's type
* (missing → "stdio", "http" → "streamable-http").
*
* @param configPath - Path to the config file (relative to process.cwd() or absolute)
* @returns The parsed MCPConfig with normalized server types
* @throws Error if the file is missing, cannot be loaded, parsed, or is invalid
*/
function loadMcpServersConfig(configPath: string): MCPConfig {
try {
const resolvedPath = resolve(process.cwd(), configPath);
if (!existsSync(resolvedPath)) {
throw new Error(`Config file not found: ${resolvedPath}`);
}
const configContent = readFileSync(resolvedPath, "utf-8");
const config = JSON.parse(configContent) as MCPConfig;
if (!config.mcpServers) {
throw new Error("Configuration file must contain an mcpServers element");
}
const normalizedServers: Record<string, MCPServerConfig> = {};
for (const [name, raw] of Object.entries(config.mcpServers)) {
normalizedServers[name] = normalizeServerType(
raw as unknown as Record<string, unknown> & { type?: string },
);
}
return { ...config, mcpServers: normalizedServers };
} catch (error) {
if (error instanceof Error) {
throw new Error(`Error loading configuration: ${error.message}`);
}
throw new Error("Error loading configuration: Unknown error");
}
}
/**
* Loads a single server config from an MCP config file by name.
* Delegates to loadMcpServersConfig (file existence and type normalization are done there).
*/
function loadServerFromConfig(
configPath: string,
serverName: string,
): MCPServerConfig {
const config = loadMcpServersConfig(configPath);
if (!config.mcpServers[serverName]) {
const available = Object.keys(config.mcpServers).join(", ");
throw new Error(
`Server '${serverName}' not found in config file. Available servers: ${available}`,
);
}
return config.mcpServers[serverName];
}
/** Build one MCPServerConfig from ad-hoc options (no config file). */
function buildConfigFromOptions(options: ServerConfigOptions): MCPServerConfig {
const target = options.target ?? [];
const first = target[0];
const rest = target.slice(1);
const urlFromTarget =
first && (first.startsWith("http://") || first.startsWith("https://"))
? first
: null;
const url = urlFromTarget ?? options.serverUrl ?? null;
if (url) {
if (rest.length > 0 && urlFromTarget) {
throw new Error("Arguments cannot be passed to a URL-based MCP server.");
}
let transportType: "sse" | "streamable-http";
const t =
options.transport === "http" ? "streamable-http" : options.transport;
if (t === "sse" || t === "streamable-http") {
transportType = t;
} else {
const u = new URL(url);
if (u.pathname.endsWith("/mcp")) {
transportType = "streamable-http";
} else if (u.pathname.endsWith("/sse")) {
transportType = "sse";
} else {
throw new Error(
`Transport type not specified and could not be determined from URL: ${url}.`,
);
}
}
if (transportType === "sse") {
const config: SseServerConfig = { type: "sse", url };
if (options.headers && Object.keys(options.headers).length > 0) {
config.headers = options.headers;
}
return config;
}
const config: StreamableHttpServerConfig = { type: "streamable-http", url };
if (options.headers && Object.keys(options.headers).length > 0) {
config.headers = options.headers;
}
return config;
}
if (target.length === 0 || !first) {
throw new Error(
"Target is required. Specify a URL or a command to execute.",
);
}
if (options.transport && options.transport !== "stdio") {
throw new Error("Only stdio transport can be used with local commands.");
}
const config: StdioServerConfig = { type: "stdio", command: first };
if (rest.length > 0) config.args = rest;
if (options.env && Object.keys(options.env).length > 0)
config.env = options.env;
if (options.cwd?.trim()) config.cwd = options.cwd.trim();
return config;
}
/** Apply env/cwd overrides to a stdio config; headers to sse/streamable-http. */
function applyOverrides(
config: MCPServerConfig,
overrides: {
env?: Record<string, string>;
cwd?: string;
headers?: Record<string, string>;
},
): MCPServerConfig {
if (config.type === "stdio") {
const c = { ...config } as StdioServerConfig;
if (overrides.env && Object.keys(overrides.env).length > 0) {
c.env = { ...(c.env ?? {}), ...overrides.env };
}
if (overrides.cwd) c.cwd = overrides.cwd;
return c;
}
if (config.type === "sse" || config.type === "streamable-http") {
const c = { ...config };
if (overrides.headers && Object.keys(overrides.headers).length > 0) {
c.headers = { ...(c.headers ?? {}), ...overrides.headers };
}
return c;
}
return config;
}
export type ResolveServerConfigsMode = "single" | "multi";
/**
* Resolves server config(s) from options and mode. Used by all runners.
* Single mode: one config (from file + overrides, or from args).
* Multi mode: all servers from file (with optional env/cwd/headers overrides), or one from args; errors if config path + transport/serverUrl/positional.
*/
export function resolveServerConfigs(
options: ServerConfigOptions,
mode: ResolveServerConfigsMode,
): MCPServerConfig[] {
const hasConfigPath = Boolean(options.configPath?.trim());
const hasAdHoc =
(options.target && options.target.length > 0) ||
Boolean(options.transport) ||
Boolean(options.serverUrl);
if (mode === "single") {
if (hasConfigPath && options.serverName) {
const config = loadServerFromConfig(
options.configPath!,
options.serverName,
);
return [
applyOverrides(config, {
env: options.env,
cwd: options.cwd,
headers: options.headers,
}),
];
}
if (hasConfigPath && !options.serverName) {
const configPath = options.configPath!;
const mcpConfig = loadMcpServersConfig(configPath);
const servers = Object.keys(mcpConfig.mcpServers);
if (servers.length === 0)
throw new Error("No servers found in config file");
if (servers.length > 1) {
throw new Error(
`Multiple servers found in config file. Please specify one with --server. Available servers: ${servers.join(", ")}`,
);
}
const serverName = servers[0];
if (!serverName) throw new Error("No servers found in config file");
const config = loadServerFromConfig(configPath, serverName);
return [
applyOverrides(config, {
env: options.env,
cwd: options.cwd,
headers: options.headers,
}),
];
}
return [buildConfigFromOptions(options)];
}
if (mode === "multi") {
if (hasConfigPath && hasAdHoc) {
throw new Error(
"In multi-server mode with a config file, do not pass --transport, --server-url, or positional command/URL. Use only --config with optional -e, --cwd, --header.",
);
}
if (hasConfigPath && options.configPath) {
const configPath = options.configPath;
const mcpConfig = loadMcpServersConfig(configPath);
const configs = Object.values(mcpConfig.mcpServers).map((c) =>
applyOverrides({ ...c } as MCPServerConfig, {
env: options.env,
cwd: options.cwd,
headers: options.headers,
}),
);
return configs;
}
return [buildConfigFromOptions(options)];
}
return [];
}
/**
* Returns named server configs from a config file (multi-server). Use when the caller
* needs server names (e.g. TUI). Errors if config path is missing or if ad-hoc options
* (target, transport, serverUrl) are also provided.
*/
export function getNamedServerConfigs(
options: ServerConfigOptions,
): Record<string, MCPServerConfig> {
const hasConfigPath = Boolean(options.configPath?.trim());
const hasAdHoc =
(options.target && options.target.length > 0) ||
Boolean(options.transport) ||
Boolean(options.serverUrl);
if (!hasConfigPath) {
throw new Error("Config path is required for getNamedServerConfigs.");
}
if (hasAdHoc) {
throw new Error(
"With a config file, do not pass --transport, --server-url, or positional command/URL. Use only --config with optional -e, --cwd, --header.",
);
}
const mcpConfig = loadMcpServersConfig(options.configPath!);
const result: Record<string, MCPServerConfig> = {};
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
result[name] = applyOverrides(
{ ...config },
{
env: options.env,
cwd: options.cwd,
headers: options.headers,
},
);
}
return result;
}