inspector/core/mcp/node/config.ts
Cliff Hall 834fd2d77d
chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory (#1900)
* chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory (#1838)

eslint 9 pulled `minimatch@3` → `brace-expansion@1.1.16`, which carries
GHSA-mh99-v99m-4gvg (high, DoS via unbounded expansion length). It appeared in
all five lockfiles — the root plus each of the four clients, none of which is an
npm workspace, so all five had to move together.

Deferred from the pre-2.0.0 sweep because it is dev-scope, not attacker-
controlled, and a major bump across five packages was the wrong thing to attempt
immediately before an irreversible publish. This is that work.

## What moved

- `eslint` `^9.39.4/5` → `^10.8.0` and `@eslint/js` → `^10.0.1`, in all five
  manifests.
- `eslint-plugin-react-hooks` `^7.0.1` → `^7.1.1` in web and tui. Required, not
  cosmetic: 7.0.1's peer range stops at `^9.0.0`, so installing it alongside
  eslint 10 fails `ERESOLVE`.
- Everything else already declared eslint 10 support and stayed put:
  `typescript-eslint@^8.56.1` (`^8.57 || ^9 || ^10`),
  `eslint-plugin-react-refresh@^0.5.2` (`^9 || ^10`),
  `eslint-plugin-storybook@^10.2.19` (`>=8`).

## Config migration

None was needed. All five configs were already flat (`eslint.config.js` with
`defineConfig`/`globalIgnores` from `eslint/config`), which is the format v10
keeps and the only one it still supports. The root config's `core/` and shared-
surface blocks — including the `_`-prefix `argsIgnorePattern` /
`varsIgnorePattern` / `caughtErrorsIgnorePattern` — are unchanged and still gate
`lint:core` and `lint:shared`.

No rule was disabled, downgraded, or scoped away to make the new eslint pass.
v10 surfaced 21 real violations across three rules; all 21 are fixed in the code.

### `preserve-caught-error` (new in `eslint:recommended`) — 15 sites

A `throw new Error(msg)` inside a `catch` silently drops the original error.
Each now passes `{ cause }`, so the underlying failure survives into the chain.
Messages are unchanged, so nothing that asserts on them moves. 12 in `core/`,
2 in `clients/cli`, 1 in `test-servers/src`.

### `no-useless-assignment` (new in `eslint:recommended`) — 2 sites

`clients/cli/src/cli.ts`'s `optionArgs` and `test-servers/src/test-server-oauth`
's `valid` were initialized and then unconditionally overwritten on every path.
Both are now declared without the dead initializer; TypeScript's definite-
assignment analysis covers the remaining paths.

### `react-hooks/set-state-in-effect` — 8 sites (web)

These were false negatives the plugin fixed in 7.1.0, not a rule we newly opted
into: `set-state-in-effect` has been `error` in `flat/recommended` since 7.0.1,
and the web config has always extended it. Every one was the same anti-pattern —
resetting or re-syncing local state from a prop inside a `useEffect`, which
paints the stale value for one frame and then renders again to correct it.

All eight now use a new `useValueChange(value, onChange)` hook
(`clients/web/src/hooks/useValueChange.ts`), which is React's documented
"adjusting state during render": compare against the previous render's value
with `Object.is` and call back *during* render, so React discards the
in-progress output before it reaches the DOM.

- `MrtrConversation`, `ProtocolEntry`, `TaskCard` — mirror `isListExpanded`.
- `NetworkEntry` — same mirror, plus the "Reveal in Network" force-open. The
  reveal's rAF scroll stays an effect (real external-system work); only the
  `setIsExpanded(true)` moved. `isExpanded` is now seeded from
  `isListExpanded || revealed`, since a render-time sync fires on change and so
  cannot cover an entry that mounts already revealed.
- `PromptArgumentsForm`, `ResourceTemplatePanel` — reset on template/prompt
  switch.
- `ServerConfigModal` — reset on open. Keyed on `opened ? initial : undefined`,
  which collapses "opened flipped" and "initial changed while open" into one
  value; `initial` is already memoized, so it cannot thrash.

`useValueChange` has its own test file, and `ServerConfigModal` gains an
open→edit→close→reopen test (the close leg was previously untested, which is
what left the new guard uncovered).

## Advisory delta

`npm audit`, per package, before → after:

| Package             | before                     | after                      |
| ------------------- | -------------------------- | -------------------------- |
| root                | 9 (1 low, 6 mod, 2 high)   | 8 (1 low, 6 mod, 1 high)   |
| `clients/web`       | 19 (3 low, 6 mod, 6 high, 4 crit) | 17 (3 low, 6 mod, 4 high, 4 crit) |
| `clients/cli`       | 6 (2 low, 4 high)          | 4 (2 low, 2 high)          |
| `clients/tui`       | 4 (1 low, 3 high)          | 2 (1 low, 1 high)          |
| `clients/launcher`  | 3 (3 high)                 | 1 (1 high)                 |

`brace-expansion` is gone from all five — `eslint@10` resolves `minimatch@10` →
`brace-expansion@5.0.9`. Dropping `@eslint/eslintrc` (removed in v10) also took
`js-yaml` with it, clearing GHSA-52cp-r559-cp3m (high) from all four clients as
a bonus. The remainder are unrelated and tracked separately.

`npm run ci` passes end to end — validate, the per-file ≥90 coverage gate, the
build gate, all five smokes (including `smoke:tui`, run locally on a real TTY),
and 462 Storybook tests.

Also documents the effect rule in AGENTS.md and mirrors it into
`.github/copilot-instructions.md`, since it is now something a reviewer cites.

Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: state useValueChange's referential-stability requirement (#1838)

Copilot review on #1900: the hook compares with `Object.is`, so an object or
array literal rebuilt in the component body compares unequal every render.
Because the callback is what updates state, that is not merely a wasted render —
it is an infinite loop.

Every current caller passes a primitive (`boolean`, `string`) or an already-
memoized value, so nothing changes behaviorally. But the hazard was implicit,
and the next caller is the one it would bite.

Documented on the hook itself, and one clause added to the AGENTS.md rule with
its mirror in `.github/copilot-instructions.md`. No runtime guard: the
requirement is the same one a `useEffect` dependency array carries, and here the
failure is loud and immediate rather than silent.

`npm run ci` passes.

Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): stop asserting the --tool-args-json catch binding is an Error (#1838)

Copilot review round 2 on #1900: the `--tool-args-json` parse handler formatted
its message with `(e as Error).message`. A `catch` binding is `unknown`, so that
cast is an assertion rather than a check — a thrown non-Error would render
`--tool-args-json is not valid JSON: undefined`, which points the user at
nothing.

`JSON.parse` does only throw `SyntaxError` today, so this is not a live bug. But
the cast is the kind AGENTS.md asks us not to leave unjustified, the same
handler was already being touched here to add `{ cause: e }`, and every other
site in this PR uses the guarded form. Now it does too.

`npm run ci` passes.

Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: require useValueChange's onChange to be render-pure (#1838)

Copilot review round 3 on #1900: `onChange` is called during render, so it is
bound by render-phase purity — and the web client mounts under `StrictMode`,
which deliberately double-renders in development. A callback doing anything
external would run an unpredictable number of times, and concurrent React can
abandon an in-progress render entirely.

Every current caller already passes a callback that only calls `setState`, so
nothing changes. But "runs during render" is the whole point of the hook and the
constraint that follows from it was left implicit.

Stated on the hook, with the same clause in the AGENTS.md rule and its
`.github/copilot-instructions.md` mirror. All three now point at `NetworkEntry`
as the worked example of the split: the reveal's force-open is a state update
and lives in `useValueChange`, while its `requestAnimationFrame` scroll stays a
`useEffect`.

`npm run ci` passes.

Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:13:30 -04:00

455 lines
15 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, resolve } from "path";
import { getDefaultMcpConfigPath } from "../../storage/store-io.js";
import type {
MCPConfig,
MCPServerConfig,
StdioServerConfig,
SseServerConfig,
StreamableHttpServerConfig,
} from "../types.js";
import { normalizeServerType } from "../serverList.js";
import { toRecord } from "../../json/jsonUtils.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 {
/**
* Writable catalog file (`--catalog` / `MCP_CATALOG_PATH`): seeded as an empty
* catalog if it doesn't exist, then served. This is the slot the no-flag
* default (`~/.mcp-inspector/mcp.json`) fills. Wins over `configPath`.
*/
catalogPath?: string;
/**
* Read-only session file (`--config`): served as-is and never written, seeded,
* or migrated. Errors if absent (so a typo'd path is a clear error, not a
* silently empty list). Used when `catalogPath` is unset.
*/
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>;
}
/**
* 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 };
}
/** On-disk contents of a freshly seeded empty catalog (pretty-printed). */
const EMPTY_CATALOG_CONTENT = `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`;
/**
* Write an empty catalog (`{ "mcpServers": {} }`) to `resolvedPath`, creating
* parent directories. Seeds a writable catalog on first run so CLI/TUI match
* the web backend instead of erroring on a missing default file.
*/
function seedEmptyCatalog(resolvedPath: string): void {
mkdirSync(dirname(resolvedPath), { recursive: true });
writeFileSync(resolvedPath, EMPTY_CATALOG_CONTENT, { mode: 0o600 });
}
/**
* Read the raw contents of a server-list file. A missing *writable* catalog is
* seeded as an empty catalog and its contents returned; a missing *read-only*
* config throws so a typo'd path is a clear error, not a silently empty list.
*/
function readServerListContent(configPath: string, writable: boolean): string {
const resolvedPath = resolve(process.cwd(), configPath);
if (!existsSync(resolvedPath)) {
if (writable) {
seedEmptyCatalog(resolvedPath);
return EMPTY_CATALOG_CONTENT;
}
throw new Error(`Config file not found: ${resolvedPath}`);
}
return readFileSync(resolvedPath, "utf-8");
}
/**
* Loads and validates an MCP servers configuration file.
* Seeds an empty catalog when `writable` and the file is missing; otherwise a
* missing file throws. Normalizes each server's type (missing → "stdio",
* "http" → "streamable-http").
*
* @param configPath - Path to the config file (relative to process.cwd() or absolute)
* @param writable - true for a `--catalog`/default source (seed-if-missing); false for `--config` (error-if-missing)
* @returns The parsed MCPConfig with normalized server types
* @throws Error if a read-only file is missing, or any file cannot be loaded, parsed, or is invalid
*/
function loadMcpServersConfig(
configPath: string,
writable: boolean,
): MCPConfig {
try {
const configContent = readServerListContent(configPath, writable);
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(toRecord(raw));
}
return { ...config, mcpServers: normalizedServers };
} catch (error) {
if (error instanceof Error) {
throw new Error(`Error loading configuration: ${error.message}`, {
cause: error,
});
}
throw new Error("Error loading configuration: Unknown error", {
cause: 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,
writable: boolean,
): MCPServerConfig {
const config = loadMcpServersConfig(configPath, writable);
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 };
return config;
}
const config: StreamableHttpServerConfig = { type: "streamable-http", url };
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. SSE / streamable-http configs
* carry no overridable per-request fields here — custom headers live in
* `InspectorServerSettings.headers` (the persisted per-server settings node),
* not on `MCPServerConfig`. */
export function applyOverrides(
config: MCPServerConfig,
overrides: {
env?: Record<string, string>;
cwd?: 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?.trim()) c.cwd = overrides.cwd.trim();
return c;
}
return config;
}
export type ResolveServerConfigsMode = "single" | "multi";
export function hasAdHocServerOptions(options: ServerConfigOptions): boolean {
return (
(options.target != null && options.target.length > 0) ||
Boolean(options.transport) ||
Boolean(options.serverUrl?.trim())
);
}
/**
* Identify the active server-list source and whether it's writable.
* `--catalog` (writable: seeded if missing) wins over `--config` (read-only:
* served as-is, never seeded/written). Returns null when neither is set
* (ad-hoc / no source).
*/
export function resolveServerSource(
options: ServerConfigOptions,
): { path: string; writable: boolean } | null {
if (options.catalogPath?.trim()) {
return { path: options.catalogPath, writable: true };
}
if (options.configPath?.trim()) {
return { path: options.configPath, writable: false };
}
return null;
}
export interface ServerSourceFlags {
hasCatalog: boolean;
hasConfig: boolean;
hasAdHoc: boolean;
}
/**
* Validate the `--catalog` / `--config` / ad-hoc combination shared by all
* runners (mirrors the *source-selection* portion of the web `run-web` conflict
* matrix). It deliberately omits web's `--header` + `--catalog`/`--config`
* rejection: unlike web, the CLI/TUI merge `--header` into per-server settings
* (`headersToServerSettings` / `mergeSettings`), so headers are allowed
* alongside a catalog/config here. Returns an error message for an illegal
* combination, or null when the flags are coherent.
*/
export function serverSourceConflict(flags: ServerSourceFlags): string | null {
if (flags.hasCatalog && flags.hasConfig) {
return "--catalog and --config are mutually exclusive. --catalog is the writable catalog; --config is a read-only session file.";
}
if (flags.hasCatalog && flags.hasAdHoc) {
return "--catalog cannot be combined with an ad-hoc server URL/command.";
}
if (flags.hasConfig && flags.hasAdHoc) {
return "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file.";
}
return null;
}
/**
* Public catalog/config loader for runners that need the raw server map (e.g.
* the TUI, which derives per-server settings via `mcpConfigToServerEntries`).
* Applies the same seed-if-writable / error-if-read-only semantics as
* `resolveServerConfigs`; server `type` fields are normalized.
*/
export function readServerListFile(
configPath: string,
writable: boolean,
): MCPConfig {
return loadMcpServersConfig(configPath, writable);
}
/**
* When no `--catalog`, no `--config`, and no ad-hoc target is given, default the
* writable catalog to ~/.mcp-inspector/mcp.json (the same file the web backend
* uses). Fills the *catalog* (writable, seed-if-missing) slot — not `--config`.
*/
export function withDefaultCatalogPath(
options: ServerConfigOptions,
): ServerConfigOptions {
if (
options.catalogPath?.trim() ||
options.configPath?.trim() ||
hasAdHocServerOptions(options)
) {
return options;
}
return { ...options, catalogPath: getDefaultMcpConfigPath() };
}
/**
* Resolves server config(s) from explicit options and mode.
* 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 source = resolveServerSource(options);
const hasAdHoc = hasAdHocServerOptions(options);
if (mode === "single") {
if (source && options.serverName) {
const config = loadServerFromConfig(
source.path,
options.serverName,
source.writable,
);
return [
applyOverrides(config, {
env: options.env,
cwd: options.cwd,
}),
];
}
if (source && !options.serverName) {
const mcpConfig = loadMcpServersConfig(source.path, source.writable);
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(
source.path,
serverName,
source.writable,
);
return [
applyOverrides(config, {
env: options.env,
cwd: options.cwd,
}),
];
}
return [buildConfigFromOptions(options)];
}
if (mode === "multi") {
if (source && 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.",
);
}
if (source) {
const mcpConfig = loadMcpServersConfig(source.path, source.writable);
const configs = Object.values(mcpConfig.mcpServers).map((c) =>
applyOverrides({ ...c } as MCPServerConfig, {
env: options.env,
cwd: options.cwd,
}),
);
return configs;
}
return [buildConfigFromOptions(options)];
}
return [];
}
/**
* Returns named server configs from a catalog/config file (multi-server). Use
* when the caller needs server names. Resolves the active source via
* `resolveServerSource` (a writable `--catalog` is seeded if missing; a
* read-only `--config` errors if absent). Errors if neither source is set or if
* ad-hoc options (target, transport, serverUrl) are also provided.
*/
export function getNamedServerConfigs(
options: ServerConfigOptions,
): Record<string, MCPServerConfig> {
const source = resolveServerSource(options);
const hasAdHoc = hasAdHocServerOptions(options);
if (!source) {
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.",
);
}
const mcpConfig = loadMcpServersConfig(source.path, source.writable);
const result: Record<string, MCPServerConfig> = {};
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
result[name] = applyOverrides(
{ ...config },
{
env: options.env,
cwd: options.cwd,
},
);
}
return result;
}