inspector/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts
Sam Morrow 5d91d4acd0
fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1847)
* fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1846)

A strict modern (2026-07-28) HTTP server (e.g. the GitHub MCP Server)
rejects a tools/call whose argument carries an `x-mcp-header` annotation
when the matching `Mcp-Param-*` request header is missing (-32020
"header mismatch"). The Inspector never sent that header.

The SDK only mirrors inside `client.callTool()` (and skips it entirely in
a browser environment), but the Inspector routes tools/call through
`client.request()` for manual MRTR driving (#1704), so the SDK never
mirrors for us — on any transport. On top of that, the remote (web)
transport dropped per-request headers before they reached the backend's
upstream fetch.

- core/json/xMcpHeader.ts: port the SDK's non-public `buildMcpParamHeaders`
  + value encoding (Base64 sentinel for non-ASCII/whitespace/empty values)
  and add `mcpParamHeadersForTool`.
- InspectorClient.attemptToolCall: on a modern connection, build the
  headers from the tool's declarations + call arguments and attach them to
  the tools/call request options. `Protocol.request` forwards `headers`
  to the transport and preserves them across MRTR retry legs, so the
  direct StreamableHTTP transport applies them. Fixes CLI/TUI.
- RemoteClientTransport.requestSend + /api/mcp/send + RemoteSendRequest:
  forward per-send headers and apply them to the backend's upstream
  transport.send. The browser can't set cross-origin headers, but the
  Node backend can — where the Inspector's real request is issued. Fixes web.

Verified end-to-end against the live GitHub MCP Server (modern HTTP).

Closes #1846

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd

* review: harden backend header allowlist, fix breadcrumbs, trim comments

Address Copilot review on #1847:
- server.ts: restrict client-supplied per-send headers to the `Mcp-Param-`
  prefix (string values only) via `mcpParamHeadersOnly` before applying them
  upstream, so the per-send channel can't inject arbitrary headers (e.g.
  Authorization). `settings.headers` remains the channel for configured
  upstream headers. Covered by new unit tests.
- Fix stale `#1810` breadcrumbs → `#1846` in the two new tests.
- Trim the verbose mirroring comments in inspectorClient/types/remote transport.
- Drop the redundant base64-on-the-wire integration test (encoding is unit-
  tested; wire delivery is proven by the verbatim test), remove two unneeded
  test casts, and cover the boolean `true` branch.

Not changed (declined with reason on the PR): buildMcpParamHeaders remains a
faithful port of the SDK's helper and does not re-validate values against the
declared JSON Schema type — arg types are enforced by schema validation on
both sides, and matching the SDK's conforming wire output is the module's goal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd

* review: mirror Mcp-Param-* on the task-augmented tools/call too

Address the maintainer review on #1847:

- `callToolStream` (the "Run as task" path) built its own request options and
  never mirrored, so a task-augmented `tools/call` on an annotated tool was
  still rejected by a strict modern server with -32020. It now mirrors like the
  plain path.
- Extract the two blocks both `tools/call` entry points had duplicated —
  `convertStringToolArgs` (the string-arg coercion) and
  `applyMirroredParamHeaders` (the SEP-2243 mirroring) — so the paths can't
  drift again. That duplication is why the task path was missed.
- Regression test: `callToolStream` against the modern `get_weather` server
  asserts the POST carries `Mcp-Param-City` (verified failing without the fix).
- README: the modern-network showcase claimed mirroring is skipped in the
  browser so the web client gets -32020, and that CLI/TUI mirror. Both are now
  wrong — the Inspector mirrors itself, on every client. AGENTS: note that
  xMcpHeader.ts now also builds the wire headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t

* docs: correct the stale "browser omits Mcp-Param-*" copy

Both statements predate this PR and are now the opposite of true, per the
wire capture on #1847 (proxy-observed upstream headers, web leg included).

- ToolDetailPanel: the mirrored-headers note claimed the browser omits
  `Mcp-Param-*`. It now says the web client's headers are applied by the Node
  backend that issues the upstream request.
- createRemoteFetch: the header comment claimed an `x-mcp-header`-annotated
  tool is "uncallable from the web client against a strict modern server" —
  the exact wrong conclusion to reach while debugging a -32020. It now records
  that the Inspector builds the headers itself, and that they do NOT travel
  this `/api/fetch` proxy path: they ride the transport's per-send `headers`
  through `/api/mcp/send`.

Copy only — no test asserted the old note text (ToolDetailPanel.test.tsx
matches the section title only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd
2026-07-28 19:19:48 -04:00

1058 lines
35 KiB
TypeScript

/**
* Supplemental coverage for createRemoteApp (core/mcp/remote/node/server.ts).
*
* Targets the remaining uncovered branches the broader suites don't exercise:
* - forwardLogEvent message/binding shape variants (/api/log)
* - validateSettings per-field rejection branches
* - /api/fetch headers / streaming content-type / network-error paths
* - normalizeMcpServers malformed-field drops, on both the logger and the
* console.warn fallback path
* - generic (non-keychain) 500 catch on POST / PUT / DELETE / order
* - keychain-unavailable migration with a logger (fileLogger.warn branch)
* - GET fast-path re-check when a concurrent write removed the plaintext
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, rmSync, 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 type pinoType from "pino";
import {
createRemoteApp,
requestIdForSendWait,
mcpParamHeadersOnly,
} from "@inspector/core/mcp/remote/node/server.js";
import {
InMemorySecretStore,
KeychainUnavailableError,
type SecretStore,
} from "@inspector/core/auth/node/secret-store.js";
interface Harness {
baseUrl: string;
server: ServerType;
configPath: string;
tempDir: string;
}
interface StartOpts {
secretStore?: SecretStore;
logger?: pinoType.Logger;
seedConfig?: string;
}
async function start(opts: StartOpts = {}): Promise<Harness> {
const tempDir = mkdtempSync(join(tmpdir(), "inspector-server-extra-"));
const configPath = join(tempDir, "mcp.json");
if (opts.seedConfig !== undefined) {
writeFileSync(configPath, opts.seedConfig);
}
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: configPath,
initialConfig: { defaultEnvironment: {} },
secretStore: opts.secretStore ?? new InMemorySecretStore(),
logger: opts.logger,
});
const { baseUrl, server } = await new Promise<{
baseUrl: string;
server: ServerType;
}>((resolve, reject) => {
const s = 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: s });
},
);
s.on("error", reject);
});
return { baseUrl, server, configPath, tempDir };
}
async function stop(h: Harness): Promise<void> {
await new Promise<void>((r) => h.server.close(() => r()));
try {
rmSync(h.tempDir, { recursive: true });
} catch {
/* ignore */
}
}
/**
* Minimal in-memory pino-shaped logger that captures forwarded records so a
* test can assert which level/object/message the server emitted.
*/
function makeCapturingLogger(): {
logger: pinoType.Logger;
records: Array<{ level: string; args: unknown[] }>;
} {
const records: Array<{ level: string; args: unknown[] }> = [];
const mk =
(level: string) =>
(...args: unknown[]) => {
records.push({ level, args });
};
// Only the methods the server reaches are needed; cast through unknown to
// satisfy the pino.Logger surface without pulling in the full type.
const logger = {
info: mk("info"),
warn: mk("warn"),
error: mk("error"),
debug: mk("debug"),
trace: mk("trace"),
fatal: mk("fatal"),
} as unknown as pinoType.Logger;
return { logger, records };
}
describe("server.ts supplemental coverage", () => {
describe("requestIdForSendWait (#1630)", () => {
it("returns the id for an ordinary request", () => {
expect(
requestIdForSendWait({ jsonrpc: "2.0", id: 7, method: "tools/list" }),
).toBe(7);
});
it("does not wait for a response on subscriptions/listen (long-lived stream)", () => {
expect(
requestIdForSendWait({
jsonrpc: "2.0",
id: "listen:0",
method: "subscriptions/listen",
params: {},
}),
).toBeUndefined();
});
it("returns undefined for a notification (no id)", () => {
expect(
requestIdForSendWait({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
).toBeUndefined();
});
});
describe("mcpParamHeadersOnly (SEP-2243 upstream header allowlist, #1846)", () => {
it("keeps only Mcp-Param-* headers (case-insensitive)", () => {
expect(
mcpParamHeadersOnly({
"Mcp-Param-City": "London",
"mcp-param-owner": "octocat",
}),
).toEqual({ "Mcp-Param-City": "London", "mcp-param-owner": "octocat" });
});
it("drops non-Mcp-Param headers a client tries to inject", () => {
expect(
mcpParamHeadersOnly({
Authorization: "Bearer evil",
"X-Custom": "nope",
"Mcp-Param-City": "London",
}),
).toEqual({ "Mcp-Param-City": "London" });
});
it("drops non-string values", () => {
expect(
mcpParamHeadersOnly({
"Mcp-Param-Bad": 5,
"Mcp-Param-City": "London",
}),
).toEqual({ "Mcp-Param-City": "London" });
});
it("returns undefined when nothing survives the filter", () => {
expect(mcpParamHeadersOnly({ Authorization: "x" })).toBeUndefined();
expect(mcpParamHeadersOnly(undefined)).toBeUndefined();
expect(mcpParamHeadersOnly(null)).toBeUndefined();
expect(mcpParamHeadersOnly("not-an-object")).toBeUndefined();
});
});
describe("/api/log forwardLogEvent shapes", () => {
let h: Harness;
let records: Array<{ level: string; args: unknown[] }>;
beforeEach(async () => {
const cap = makeCapturingLogger();
records = cap.records;
h = await start({ logger: cap.logger });
});
afterEach(async () => {
await stop(h);
});
async function postLog(body: unknown): Promise<Response> {
return fetch(`${h.baseUrl}/api/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
it("forwards an event whose first message is an object (obj + msg + args)", async () => {
const res = await postLog({
level: { label: "warn", value: 40 },
bindings: [{ component: "x" }, { category: "y" }],
messages: [{ extra: 1 }, "hello", "a", "b"],
});
expect(res.status).toBe(200);
const rec = records.find((r) => r.level === "warn");
expect(rec).toBeDefined();
expect(rec!.args[0]).toMatchObject({
component: "x",
category: "y",
extra: 1,
});
expect(rec!.args[1]).toBe("hello");
expect(rec!.args.slice(2)).toEqual(["a", "b"]);
});
it("forwards an event with no messages (bindings-only object)", async () => {
const res = await postLog({
level: { label: "info", value: 30 },
bindings: [{ a: 1 }],
messages: [],
});
expect(res.status).toBe(200);
const rec = records.find((r) => r.level === "info");
expect(rec).toBeDefined();
expect(rec!.args).toEqual([{ a: 1 }]);
});
it("forwards a string-first message (bindings + msg + args)", async () => {
const res = await postLog({
level: { label: "info", value: 30 },
bindings: [{ b: 2 }],
messages: ["just a string", 1, 2],
});
expect(res.status).toBe(200);
const rec = records.find(
(r) => r.level === "info" && r.args[1] === "just a string",
);
expect(rec).toBeDefined();
expect(rec!.args[0]).toMatchObject({ b: 2 });
expect(rec!.args.slice(2)).toEqual([1, 2]);
});
it("defaults the level to info and tolerates non-array bindings/messages", async () => {
const res = await postLog({ bindings: "nope", messages: "nope" });
expect(res.status).toBe(200);
const rec = records.find((r) => r.level === "info");
expect(rec).toBeDefined();
// bindings non-array → {} ; messages non-array → [] → bindings-only call
expect(rec!.args).toEqual([{}]);
});
it("drops an event whose level has no matching logger method", async () => {
const res = await postLog({
level: { label: "nonsense", value: 1 },
messages: ["x"],
});
expect(res.status).toBe(200);
// No record captured for an unknown level.
expect(records.length).toBe(0);
});
});
describe("/api/log without a logger is a no-op", () => {
it("returns 200 and swallows the event", async () => {
const h = await start();
try {
const res = await fetch(`${h.baseUrl}/api/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: ["x"] }),
});
expect(res.status).toBe(200);
// Invalid JSON also tolerated (catch → {}).
const res2 = await fetch(`${h.baseUrl}/api/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res2.status).toBe(200);
} finally {
await stop(h);
}
});
});
describe("/api/fetch", () => {
let h: Harness;
let target: ServerType;
let targetUrl: string;
beforeEach(async () => {
h = await start();
// A tiny upstream HTTP server we can point /api/fetch at.
const { createServer } = await import("node:http");
const srv = createServer((req, res) => {
if (req.url === "/stream") {
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write("data: hi\n\n");
res.end();
return;
}
if (req.url === "/echo-headers") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ got: req.headers["x-probe"] ?? null }));
return;
}
if (req.url === "/echo-mcp") {
// Echo back every mirrored MCP header the proxy forwarded, so a test
// can assert the browser-built Mcp-* headers survive the /api/fetch
// hop verbatim (SEP-2243 header mirroring works through the proxy).
const mcp: Record<string, string | string[] | undefined> = {};
for (const [k, v] of Object.entries(req.headers)) {
if (k.toLowerCase().startsWith("mcp-")) mcp[k] = v;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(mcp));
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("plain body");
});
await new Promise<void>((resolve) =>
srv.listen(0, "127.0.0.1", () => resolve()),
);
target = srv as unknown as ServerType;
const addr = srv.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
targetUrl = `http://127.0.0.1:${port}`;
});
afterEach(async () => {
await new Promise<void>((r) =>
(target as unknown as { close: (cb: () => void) => void }).close(() =>
r(),
),
);
await stop(h);
});
it("forwards method + headers and returns the response body", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: `${targetUrl}/echo-headers`,
method: "GET",
headers: { "x-probe": "abc" },
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
ok: boolean;
status: number;
headers: Record<string, string>;
body?: string;
};
expect(body.ok).toBe(true);
expect(body.status).toBe(200);
expect(body.headers["content-type"]).toContain("application/json");
expect(JSON.parse(body.body!)).toEqual({ got: "abc" });
});
it("forwards modern MCP mirrored headers verbatim (SEP-2243 mirroring through the proxy)", async () => {
// The browser-side SDK builds Mcp-Method / Mcp-Name / Mcp-Param-* /
// MCP-Protocol-Version before calling the (proxied) fetch; /api/fetch must
// re-send them unchanged so mirroring is preserved on the browser path.
const mirrored = {
"Mcp-Method": "tools/call",
"Mcp-Name": "get_weather",
"Mcp-Param-City": "=?base64?U8OjbyBQYXVsbw==?=",
"MCP-Protocol-Version": "2026-07-28",
};
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: `${targetUrl}/echo-mcp`,
method: "POST",
headers: mirrored,
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { body?: string };
const echoed = JSON.parse(body.body!) as Record<string, string>;
expect(echoed["mcp-method"]).toBe("tools/call");
expect(echoed["mcp-name"]).toBe("get_weather");
expect(echoed["mcp-param-city"]).toBe("=?base64?U8OjbyBQYXVsbw==?=");
expect(echoed["mcp-protocol-version"]).toBe("2026-07-28");
});
it("omits the body for an event-stream content type", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stream` }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { body?: string; status: number };
expect(body.status).toBe(200);
expect(body.body).toBeUndefined();
});
it("returns 400 when url is missing", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method: "GET" }),
});
expect(res.status).toBe(400);
});
it("returns 400 on invalid JSON", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{bad",
});
expect(res.status).toBe(400);
});
it("returns 500 when the upstream fetch throws", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "http://127.0.0.1:1/nope" }),
});
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(typeof body.error).toBe("string");
});
});
describe("validateSettings rejection branches (POST /api/servers)", () => {
let h: Harness;
beforeEach(async () => {
h = await start();
});
afterEach(async () => {
await stop(h);
});
async function postSettings(settings: unknown): Promise<Response> {
return fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "srv",
config: { type: "stdio", command: "node" },
settings,
}),
});
}
const base = {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
};
it("rejects a non-object settings value", async () => {
const res = await postSettings([1, 2, 3]);
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/must be an object/);
});
it("rejects malformed headers", async () => {
const res = await postSettings({ ...base, headers: "oops" });
expect((await res.json()).error).toMatch(/headers/);
});
it("rejects malformed metadata", async () => {
const res = await postSettings({
...base,
metadata: [{ key: 1, value: "x" }],
});
expect((await res.json()).error).toMatch(/metadata/);
});
it("rejects malformed env", async () => {
const res = await postSettings({ ...base, env: "oops" });
expect((await res.json()).error).toMatch(/env/);
});
it("rejects a non-string cwd", async () => {
const res = await postSettings({ ...base, cwd: 42 });
expect((await res.json()).error).toMatch(/cwd/);
});
it("rejects a negative connectionTimeout", async () => {
const res = await postSettings({ ...base, connectionTimeout: -1 });
expect((await res.json()).error).toMatch(/connectionTimeout/);
});
it("rejects a non-numeric requestTimeout", async () => {
const res = await postSettings({ ...base, requestTimeout: "x" });
expect((await res.json()).error).toMatch(/requestTimeout/);
});
it("rejects a negative taskTtl", async () => {
const res = await postSettings({ ...base, taskTtl: -5 });
expect((await res.json()).error).toMatch(/taskTtl/);
});
it("rejects a non-boolean autoRefreshOnListChanged", async () => {
const res = await postSettings({
...base,
autoRefreshOnListChanged: "yes",
});
expect((await res.json()).error).toMatch(/autoRefreshOnListChanged/);
});
it("rejects a non-boolean paginatedLists", async () => {
const res = await postSettings({
...base,
paginatedLists: "yes",
});
expect((await res.json()).error).toMatch(/paginatedLists/);
});
it("rejects a negative maxFetchRequests", async () => {
const res = await postSettings({ ...base, maxFetchRequests: -2 });
expect((await res.json()).error).toMatch(/maxFetchRequests/);
});
it("rejects a non-string OAuth field", async () => {
const res = await postSettings({ ...base, oauthClientId: 7 });
expect((await res.json()).error).toMatch(/oauthClientId/);
});
it("rejects a non-boolean enterpriseManaged", async () => {
const res = await postSettings({ ...base, enterpriseManaged: "yes" });
expect((await res.json()).error).toMatch(/enterpriseManaged/);
});
it("rejects malformed roots", async () => {
const res = await postSettings({ ...base, roots: [{ uri: 1 }] });
expect((await res.json()).error).toMatch(/roots/);
});
it("rejects an unknown protocolEra", async () => {
const res = await postSettings({ ...base, protocolEra: "future" });
expect((await res.json()).error).toMatch(/protocolEra/);
});
it("rejects an unknown modernLogLevel (#1629)", async () => {
const res = await postSettings({ ...base, modernLogLevel: "verbose" });
expect((await res.json()).error).toMatch(/modernLogLevel/);
});
it("rejects a non-boolean-valued advertisedExtensions (#1739)", async () => {
const res = await postSettings({
...base,
advertisedExtensions: { "io.modelcontextprotocol/tasks": "yes" },
});
expect((await res.json()).error).toMatch(/advertisedExtensions/);
});
it("rejects a non-object advertisedExtensions (#1739)", async () => {
const res = await postSettings({
...base,
advertisedExtensions: ["io.modelcontextprotocol/tasks"],
});
expect((await res.json()).error).toMatch(/advertisedExtensions/);
});
it("accepts an empty advertisedExtensions map without persisting it (#1739)", async () => {
const res = await postSettings({ ...base, advertisedExtensions: {} });
expect(res.status).toBe(200);
});
it("accepts a fully-populated valid settings payload", async () => {
const res = await postSettings({
...base,
env: [{ key: "K", value: "V" }],
cwd: "/tmp",
taskTtl: 1000,
autoRefreshOnListChanged: true,
paginatedLists: true,
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
maxFetchRequests: 5,
protocolEra: "modern",
modernLogLevel: "off",
oauthClientId: "cid",
oauthScopes: "a b",
enterpriseManaged: true,
roots: [{ uri: "file:///x", name: "x" }],
});
expect(res.status).toBe(200);
});
});
describe("normalizeMcpServers malformed-field drops (console fallback, no logger)", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it("drops a legacy settings node and every malformed field, logging to console.warn", async () => {
const h = await start({
seedConfig: JSON.stringify({
mcpServers: {
// non-object entry → skipped
bad: 5,
srv: {
type: "http",
url: "https://x.test/mcp",
settings: { legacy: true },
// object with a non-string value → isStringRecord inner branch
headers: { Authorization: 123 },
metadata: [{ key: 1 }],
connectionTimeout: Infinity,
requestTimeout: -1,
taskTtl: "x",
maxFetchRequests: -1,
// unknown era literal → isProtocolEra branch
protocolEra: "future",
// unknown modern log level → isModernLogLevel branch (#1629)
modernLogLevel: "verbose",
// enterpriseManaged non-boolean → isOauthObject inner branch
oauth: { enterpriseManaged: "yes" },
// a non-string `name` → isRootArray inner branch
roots: [{ uri: "file:///ok", name: 5 }],
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
mcpServers: Record<string, Record<string, unknown>>;
};
const srv = body.mcpServers.srv;
expect(srv).toBeDefined();
// type:"http" normalized to streamable-http
expect(srv.type).toBe("streamable-http");
// Every malformed field dropped.
for (const k of [
"settings",
"headers",
"metadata",
"connectionTimeout",
"requestTimeout",
"taskTtl",
"maxFetchRequests",
"protocolEra",
"modernLogLevel",
"oauth",
"roots",
]) {
expect(srv).not.toHaveProperty(k);
}
expect(body.mcpServers.bad).toBeUndefined();
expect(warnSpy).toHaveBeenCalled();
} finally {
await stop(h);
}
});
});
describe("normalizeMcpServers additional validator inner branches", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it("drops oauth with a non-string clientId and roots with a null entry", async () => {
const h = await start({
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
// clientId non-string → isOauthObject clientId branch
oauth: { clientId: 5 },
// a null entry → isRootArray `e === null` branch
roots: [null],
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
mcpServers: Record<string, Record<string, unknown>>;
};
expect(body.mcpServers.srv).not.toHaveProperty("oauth");
expect(body.mcpServers.srv).not.toHaveProperty("roots");
} finally {
await stop(h);
}
});
});
describe("plaintext-secret migration over a mixed config", () => {
it("migrates servers with secrets and passes through servers without", async () => {
// With an available keychain, the GET slow path migrates plaintext into
// the store. A mixed config (one server with a secret, one without)
// exercises both the migrate branch and the no-secret continue branch.
const store = new InMemorySecretStore();
const h = await start({
secretStore: store,
seedConfig: JSON.stringify({
mcpServers: {
plain: { type: "stdio", command: "node" },
withSecret: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "shh" },
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
mcpServers: Record<string, Record<string, unknown>>;
};
// Both entries present; the secret value is rehydrated from keychain.
expect(body.mcpServers.plain).toBeDefined();
expect(body.mcpServers.withSecret).toBeDefined();
// Disk no longer holds the plaintext clientSecret (migrated out).
const onDisk = readFileSync(h.configPath, "utf-8");
expect(onDisk).not.toContain("shh");
} finally {
await stop(h);
}
});
});
describe("normalizeMcpServers malformed-field drops (logger path)", () => {
it("routes the drop warnings through fileLogger.warn", async () => {
const cap = makeCapturingLogger();
const h = await start({
logger: cap.logger,
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "stdio",
command: "node",
headers: 123,
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const warned = cap.records.filter((r) => r.level === "warn");
expect(warned.length).toBeGreaterThan(0);
} finally {
await stop(h);
}
});
});
describe("generic 500 catch on mutating routes", () => {
// A secret store whose deleteAllForServer throws a plain (non-keychain)
// error drives the generic 500 catch on POST and DELETE; the writeKeychain
// path covers PUT.
class ThrowingSecretStore implements SecretStore {
async get(): Promise<string | null> {
return null;
}
async set(): Promise<void> {
throw new Error("boom-set");
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
throw new Error("boom-sweep");
}
}
it("POST returns 500 when the keychain sweep throws a generic error", async () => {
const h = await start({ secretStore: new ThrowingSecretStore() });
try {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "srv",
config: { type: "stdio", command: "node" },
}),
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to add server/);
} finally {
await stop(h);
}
});
it("DELETE returns 500 when the keychain sweep throws a generic error", async () => {
const h = await start({
secretStore: new ThrowingSecretStore(),
seedConfig: JSON.stringify({
mcpServers: { srv: { type: "stdio", command: "node" } },
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "DELETE",
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to delete server/);
} finally {
await stop(h);
}
});
it("PUT returns 500 when the keychain write throws a generic error", async () => {
const h = await start({
secretStore: new ThrowingSecretStore(),
seedConfig: JSON.stringify({
mcpServers: {
srv: { type: "streamable-http", url: "https://x.test/mcp" },
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
oauthClientSecret: "shh",
},
}),
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to update server/);
} finally {
await stop(h);
}
});
it("order returns 500 when the disk write throws", async () => {
// Point the config at a path whose parent is a file, so the atomic
// write fails with ENOTDIR — a generic error surfaced as 500.
const tempDir = mkdtempSync(join(tmpdir(), "inspector-order-500-"));
const filePath = join(tempDir, "afile");
writeFileSync(filePath, "x");
const badConfigPath = join(filePath, "mcp.json");
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: badConfigPath,
initialConfig: { defaultEnvironment: {} },
secretStore: new InMemorySecretStore(),
});
const { baseUrl, server } = await new Promise<{
baseUrl: string;
server: ServerType;
}>((resolve, reject) => {
const s = 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: s });
},
);
s.on("error", reject);
});
try {
// readMcpConfig sees ENOENT (parent is a file) → empty list, so an
// empty order set-matches and we proceed to the failing write.
const res = await fetch(`${baseUrl}/api/servers/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order: [] }),
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to reorder servers/);
} finally {
await new Promise<void>((r) => server.close(() => r()));
rmSync(tempDir, { recursive: true });
}
});
});
describe("keychain-unavailable migration with a logger", () => {
class UnavailableStore implements SecretStore {
async get(): Promise<string | null> {
return null;
}
async set(): Promise<void> {
throw new KeychainUnavailableError(new Error("libsecret missing"));
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
/* no-op */
}
}
it("warns via the logger and preserves on-disk plaintext", async () => {
const cap = makeCapturingLogger();
const h = await start({
secretStore: new UnavailableStore(),
logger: cap.logger,
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "plaintext" },
},
},
}),
});
try {
const before = readFileSync(h.configPath, "utf-8");
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
// Disk plaintext preserved (migration abandoned).
expect(readFileSync(h.configPath, "utf-8")).toBe(before);
const warned = cap.records.filter(
(r) =>
r.level === "warn" &&
JSON.stringify(r.args).includes("Keychain unavailable"),
);
expect(warned.length).toBeGreaterThan(0);
} finally {
await stop(h);
}
});
});
describe("keychain-unavailable 503 on PUT/DELETE", () => {
class UnavailableOnWriteStore implements SecretStore {
async get(): Promise<string | null> {
return null;
}
async set(): Promise<void> {
throw new KeychainUnavailableError(new Error("libsecret missing"));
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
throw new KeychainUnavailableError(new Error("libsecret missing"));
}
}
it("PUT returns 503 when the keychain write is unavailable", async () => {
const h = await start({
secretStore: new UnavailableOnWriteStore(),
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "shh" },
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
oauthClientSecret: "new-secret",
},
}),
});
expect(res.status).toBe(503);
expect((await res.json()).error).toMatch(/libsecret missing/);
} finally {
await stop(h);
}
});
it("DELETE returns 503 when the keychain sweep is unavailable", async () => {
const h = await start({
secretStore: new UnavailableOnWriteStore(),
seedConfig: JSON.stringify({
mcpServers: { srv: { type: "stdio", command: "node" } },
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "DELETE",
});
expect(res.status).toBe(503);
expect((await res.json()).error).toMatch(/libsecret missing/);
} finally {
await stop(h);
}
});
});
describe("migratePlaintextSecrets rethrows a non-keychain error", () => {
class ThrowingOnGetStore implements SecretStore {
async get(): Promise<string | null> {
throw new Error("disk full");
}
async set(): Promise<void> {
/* unreachable in this test */
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
/* no-op */
}
}
it("GET /api/servers returns 500 when migration hits a non-KeychainUnavailableError", async () => {
const h = await start({
secretStore: new ThrowingOnGetStore(),
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "shh" },
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to read server list/);
} finally {
await stop(h);
}
});
});
});