Merge remote-tracking branch 'origin/v2/main' into v2/fix/2071-oauth-resource-metadata

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	core/mcp/node/transport.ts
This commit is contained in:
cliffhall 2026-08-24 21:26:59 -04:00
commit baf4fe954f
97 changed files with 9048 additions and 1271 deletions

View file

@ -88,9 +88,15 @@ The file is the only durable way to give a run its roots: there is no roots flag
### HTTP proxy support
Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to the Node transport shared by the CLI and the web backend — no inspector-specific flag is needed. When a proxy variable is set, outbound requests are routed through undici's `EnvHttpProxyAgent`.
Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to every Node client — CLI, TUI, and the web backend — with no inspector-specific flag. It also covers **OAuth discovery and token requests**, which run through the same fetch.
Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (`^8.5.0`, which requires Node `>= 22.19.0` — the inspector's supported floor). It is imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost.
Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (declared in the **root** manifest only; `^8.x` requires Node `>= 22.19.0`, the Inspector's supported floor). It is imported lazily on the first request and only when a proxy variable is set, so runs without a proxy configured pay no cost.
**Both halves of the pair come from userland undici — its `fetch` _and_ its `EnvHttpProxyAgent` ([#2067](https://github.com/modelcontextprotocol/inspector/issues/2067)).** The obvious-looking alternative is to hand the agent to Node's built-in `fetch` as a `dispatcher`, which is what v2.0.0–2.3.0 did. That couples two _different copies_ of undici at the dispatcher handler interface, and that interface is not stable across majors: Node 22 embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8 agent handed a built-in undici 7 handler is rejected outright — `fetch failed: invalid onRequestStart method` — and the request never leaves the process. Keeping both sides inside one copy is what makes proxying work unchanged from the Node 22.19 floor through Node 26. (Node's own `NODE_USE_ENV_PROXY` is not a substitute: it is unsupported at our 22.19 engine floor.)
Because undici's `Response` is a different class from `globalThis.Response`, the proxied fetch re-wraps each response as a genuine global `Response`, streaming preserved. Without that, `res instanceof Response` is `false` for callers that test it — including the MCP SDK, whose OAuth error formatter would degrade every message to `Raw body: [object Response]`.
`undici` is declared **only** in the root `package.json`, and every client's tsup config lists it as `external`. Both halves matter: tsup auto-externalizes what the _nearest_ manifest declares, so without the explicit entry the web and TUI bundles inlined it — and a CommonJS package inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` the first time it is used. `npm run verify:bundle-externals` is the durable guard.
## Options
@ -110,15 +116,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--prompt-name <name>` | Prompt name (for `prompts/get`). |
| `--prompt-args <key=value>` | Prompt arguments; repeat for multiple. |
| `--log-level <level>` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). |
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
| `--metadata <key=value>` | General `_meta` entries (key=value); applied to all methods. The value is JSON-parsed when it parses, so `trace={"id":"abc"}` sends a real object and `n=3` a number; anything that is not valid JSON is sent as the literal string. Merged **over** the server's persisted `metadata` from `mcp.json`, which is sent on every request without this flag (#2093). |
| `--tool-metadata <key=value>` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. |
| `--connect-timeout <ms>` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. |
| `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name <name>`: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. |
| `--format <text\|json>` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. |
| `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. |
| `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. |
`servers/show` redacts secret-bearing fields (`env` values, sensitive headers / `settings.metadata` keys, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues.
`servers/show` redacts secret-bearing fields (`env` values, sensitive headers, sensitive `settings.metadata` keys whose whole value is replaced whether or not it is structured, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues.
#### App probing (`--app-info`) and machine-readable output (`--format json`)

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { afterAll, beforeAll, describe, it, expect, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -24,8 +24,44 @@ import {
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
import type { MCPServerConfig } from "@inspector/core/mcp/index.js";
import { createServer, request } from "node:http";
describe("CLI Tests", () => {
/**
* Proxy variables are cleared for the WHOLE file, not just the proxy tests.
*
* `createProxyFetch`'s `EnvHttpProxyAgent` is memoized process-wide and captures
* its proxy URLs when it is constructed (only `NO_PROXY` is re-read per
* request). So on a machine with an ambient `HTTP_PROXY`, any earlier test that
* makes a real connection would build that agent against the ambient proxy, and
* a later test setting `HTTP_PROXY` to its own server would be talking to an
* agent that is not listening. Clearing up front means nothing can build the
* singleton before the proxy tests do.
*/
const AMBIENT_PROXY_VARS = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"NO_PROXY",
"no_proxy",
] as const;
const ambientProxyEnv: Record<string, string | undefined> = {};
beforeAll(() => {
for (const name of AMBIENT_PROXY_VARS) {
ambientProxyEnv[name] = process.env[name];
delete process.env[name];
}
});
afterAll(() => {
for (const name of AMBIENT_PROXY_VARS) {
const previous = ambientProxyEnv[name];
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
}
});
describe("Basic CLI Mode", () => {
it("should execute tools/list successfully", async () => {
const { command, args } = getTestMcpServerCommand();
@ -326,6 +362,75 @@ describe("CLI Tests", () => {
});
});
describe("HTTP proxy (#2067)", () => {
it("routes the MCP connection through HTTP_PROXY (#2067)", async () => {
// Proxy support is one line in cli.ts — `fetch: createProxyFetch()` on the
// environment — and it is what puts the proxy at the BOTTOM of the fetch
// stack, where InspectorClient's own wrappers compose over it rather than
// discarding it. `InspectorClient` sets `fetchFn` unconditionally, so the
// transport-level fallback never fires for this client; delete that line
// and CLI proxy support disappears with every core test still green.
//
// Asserted behaviorally, through a real forwarding proxy, rather than by
// inspecting the environment object: the bug this replaces was a runtime
// dispatch failure that no structural assertion would have caught.
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
const seen: string[] = [];
const proxy = createServer((req, res) => {
seen.push(req.url ?? "");
const target = new URL(req.url ?? "");
const upstream = request(
{
host: target.hostname,
port: target.port,
path: target.pathname + target.search,
method: req.method,
headers: req.headers,
},
(up) => {
res.writeHead(up.statusCode ?? 502, up.headers);
up.pipe(res);
},
);
upstream.on("error", () => {
res.writeHead(502);
res.end();
});
req.pipe(upstream);
});
try {
await server.start();
await new Promise<void>((r) => proxy.listen(0, "127.0.0.1", () => r()));
const addr = proxy.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
]);
expectCliSuccess(result);
expect(JSON.stringify(expectValidJson(result))).toContain("echo");
// The point of the test: it did not go direct.
expect(seen.length).toBeGreaterThan(0);
expect(seen.every((u) => u.startsWith(server.url))).toBe(true);
} finally {
// Only this test's own variable is cleared here. The ambient ones are
// restored by the file-level afterAll, so the memoized agent can never
// be left bound to this now-closed proxy for a later test.
delete process.env.HTTP_PROXY;
await new Promise<void>((r) => proxy.close(() => r()));
await server.stop();
}
});
});
describe("Roots capability (#1797)", () => {
it("answers a server's roots/list instead of -32601", async () => {
// The CLI used to omit `roots` when constructing its InspectorClient, so

View file

@ -3,7 +3,7 @@ import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import type { MCPServerConfig } from "@inspector/core/mcp/index.js";
import type { StoredMCPServer } from "@inspector/core/mcp/types.js";
/**
* Sentinel value for tests that don't need a real server
@ -61,10 +61,16 @@ function cleanupTempDir(dir: string) {
}
/**
* Create a test config file
* Create a test config file.
*
* Entries are `StoredMCPServer`, not bare `MCPServerConfig` — the on-disk
* `mcp.json` shape carries the Inspector-specific per-server keys (`metadata`,
* `headers`, `roots`, `protocolEra`, …) directly alongside `type`/`url`, and a
* fixture that cannot express them cannot cover the settings that reach the
* client from the catalog (#2093).
*/
export function createTestConfig(config: {
mcpServers: Record<string, MCPServerConfig>;
mcpServers: Record<string, StoredMCPServer>;
}): string {
const tempDir = createTempDir("mcp-inspector-config-");
const configPath = path.join(tempDir, "config.json");

View file

@ -63,7 +63,7 @@ export function makeFakeServerSettings(
): InspectorServerSettings {
return {
headers: [],
metadata: [],
metadata: {},
env: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { runCli } from "./helpers/cli-runner.js";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import {
expectCliSuccess,
expectCliFailure,
@ -11,7 +12,11 @@ import {
createAddTool,
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
import { NO_SERVER_SENTINEL } from "./helpers/fixtures.js";
import {
NO_SERVER_SENTINEL,
createTestConfig,
deleteConfigFile,
} from "./helpers/fixtures.js";
describe("Metadata Tests", () => {
describe("General Metadata", () => {
@ -373,16 +378,17 @@ describe("Metadata Tests", () => {
expectCliSuccess(result);
// Validate metadata values are sent as strings
// A JSON-parseable value goes out as that JSON type, not as its
// string spelling (#1910) — `_meta` takes any JSON.
const recordedRequests = server.getRecordedRequests();
const toolsListRequest = recordedRequests.find(
(r) => r.method === "tools/list",
);
expect(toolsListRequest).toBeDefined();
expect(toolsListRequest?.metadata).toEqual({
integer_value: "42",
decimal_value: "3.14159",
negative_value: "-10",
integer_value: 42,
decimal_value: 3.14159,
negative_value: -10,
});
} finally {
await server.stop();
@ -428,7 +434,7 @@ describe("Metadata Tests", () => {
}
});
it("JSON.stringifies object/array metadata (not String → [object Object])", async () => {
it("sends object/array/boolean metadata as real JSON, not stringified (#1910)", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
@ -456,9 +462,9 @@ describe("Metadata Tests", () => {
(r) => r.method === "tools/list",
);
expect(toolsListRequest?.metadata).toEqual({
nested: '{"key":"value"}',
list: "[1,2,3]",
flag: "true",
nested: { key: "value" },
list: [1, 2, 3],
flag: true,
});
} finally {
await server.stop();
@ -819,7 +825,7 @@ describe("Metadata Tests", () => {
);
expect(toolsListRequest).toBeDefined();
expect(toolsListRequest?.metadata).toEqual({
integration_test: "true",
integration_test: true,
test_phase: "all_methods",
});
} finally {
@ -866,8 +872,8 @@ describe("Metadata Tests", () => {
);
expect(toolCallRequest).toBeDefined();
expect(toolCallRequest?.metadata).toEqual({
session_id: "12345",
user_id: "67890",
session_id: 12345,
user_id: 67890,
timestamp: "2024-01-01T00:00:00Z",
request_id: "req-abc-123",
tool_session: "session-xyz-789",
@ -879,6 +885,65 @@ describe("Metadata Tests", () => {
}
});
it.each([
["an overflowing literal", "n=1e400"],
["a negative overflow", "n=-1e400"],
["one nested in an object", 'o={"a":1e400}'],
])("rejects %s rather than sending null", async (_label, pair) => {
// `JSON.parse` accepts these and yields ±Infinity, which
// `JSON.stringify` writes as `null` — so accepting the flag would
// transmit a value the user did not ask for.
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--metadata",
pair,
"--transport",
"http",
]);
expect(result.exitCode).not.toBe(0);
expect(`${result.stderr}${result.stdout}`).toMatch(/finite/i);
} finally {
await server.stop();
}
});
it("names the key but never the value when rejecting", async () => {
// The pair can carry a credential, and this message reaches stderr and
// CI logs.
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--metadata",
'credentials={"accessToken":"sk-live-nope","n":1e400}',
"--transport",
"http",
]);
const output = `${result.stderr}${result.stdout}`;
expect(result.exitCode).not.toBe(0);
expect(output).toContain("credentials");
expect(output).not.toContain("sk-live");
} finally {
await server.stop();
}
});
it("should handle metadata parsing validation", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
@ -919,8 +984,8 @@ describe("Metadata Tests", () => {
expect(toolCallRequest).toBeDefined();
expect(toolCallRequest?.metadata).toEqual({
valid_key: "valid_value",
numeric_key: "123",
boolean_key: "true",
numeric_key: 123,
boolean_key: true,
json_key: '\'{"test":"value"}\'', // Single quotes are preserved
special_key: "!@#$%^&*()",
unicode_key: "🚀🎉✨",
@ -968,4 +1033,143 @@ describe("Metadata Tests", () => {
}
});
});
describe("Per-server metadata from mcp.json (#2093)", () => {
/**
* Write a one-server catalog pointing at `url`, carrying `metadata` as the
* on-disk per-server key. Returns the catalog path; the caller deletes it.
*/
function writeCatalogWithMetadata(
url: string,
metadata: StrictJsonObject,
): string {
return createTestConfig({
mcpServers: {
"meta-server": {
type: "streamable-http",
url,
metadata,
},
},
});
}
it("applies a server's persisted metadata to every request", async () => {
// The setting belongs to the server, not to the client that reads it —
// web and the TUI already honored it, and the CLI silently did not,
// because `InspectorClient` reads `defaultMetadata` rather than falling
// back to `serverSettings.metadata`.
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
let catalogPath: string | undefined;
try {
await server.start();
catalogPath = writeCatalogWithMetadata(server.url, { tenant: "acme" });
const result = await runCli([
"--catalog",
catalogPath,
"--server",
"meta-server",
"--cli",
"--method",
"tools/list",
]);
expectCliSuccess(result);
expect(expectValidJson(result)).toHaveProperty("tools");
const toolsListRequest = server
.getRecordedRequests()
.find((r) => r.method === "tools/list");
expect(toolsListRequest).toBeDefined();
expect(toolsListRequest?.metadata).toEqual({ tenant: "acme" });
} finally {
if (catalogPath) deleteConfigFile(catalogPath);
await server.stop();
}
});
it("merges --metadata over the persisted defaults", async () => {
// `--metadata` is per-invocation and stays that way: non-colliding keys
// merge with the catalog's, and a colliding one wins (call-time keys
// override defaults in `InspectorClient.mergeMeta`).
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
let catalogPath: string | undefined;
try {
await server.start();
catalogPath = writeCatalogWithMetadata(server.url, {
tenant: "acme",
region: "eu",
});
const result = await runCli([
"--catalog",
catalogPath,
"--server",
"meta-server",
"--cli",
"--method",
"tools/list",
"--metadata",
"tenant=override",
]);
expectCliSuccess(result);
const toolsListRequest = server
.getRecordedRequests()
.find((r) => r.method === "tools/list");
expect(toolsListRequest?.metadata).toEqual({
tenant: "override",
region: "eu",
});
} finally {
if (catalogPath) deleteConfigFile(catalogPath);
await server.stop();
}
});
it("sends no _meta when the persisted metadata is empty", async () => {
// `{}` means "no defaults" — an empty map must not put a bare `_meta` on
// the wire, matching what the option-less CLI has always sent.
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
let catalogPath: string | undefined;
try {
await server.start();
catalogPath = writeCatalogWithMetadata(server.url, {});
const result = await runCli([
"--catalog",
catalogPath,
"--server",
"meta-server",
"--cli",
"--method",
"tools/list",
]);
expectCliSuccess(result);
const toolsListRequest = server
.getRecordedRequests()
.find((r) => r.method === "tools/list");
expect(toolsListRequest).toBeDefined();
expect(toolsListRequest?.metadata).toBeUndefined();
} finally {
if (catalogPath) deleteConfigFile(catalogPath);
await server.stop();
}
});
});
});

View file

@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
isOneShotMethod,
metaValueToString,
ONE_SHOT_METHODS,
SESSION_RPC_METHODS,
} from "../src/handlers/method-types.js";
@ -26,11 +25,3 @@ describe("ONE_SHOT_METHODS", () => {
expect(ONE_SHOT_METHODS).not.toContain("logging/tail");
});
});
describe("metaValueToString", () => {
it("passes strings through and JSON-encodes structured values", () => {
expect(metaValueToString("plain")).toBe("plain");
expect(metaValueToString({ a: 1 })).toBe('{"a":1}');
expect(metaValueToString([1, 2])).toBe("[1,2]");
});
});

View file

@ -12,7 +12,7 @@ import {
describe("withConnectTimeout", () => {
const baseSettings: InspectorServerSettings = {
headers: [{ key: "X-Test", value: "1" }],
metadata: [],
metadata: {},
env: [],
connectionTimeout: 42,
requestTimeout: 0,

View file

@ -219,10 +219,23 @@ describe("showServerEntry / servers/show", () => {
{ key: "Authorization", value: "Bearer x" },
{ key: "X-Custom", value: "ok" },
],
metadata: [
{ key: "Authorization", value: "Bearer meta" },
{ key: "X-Custom", value: "ok" },
],
metadata: {
Authorization: "Bearer meta",
"X-Custom": "ok",
// A structured value under a sensitive key: the redaction must replace
// the whole value, not walk into it (#1910).
"X-Api-Key": { primary: "sk-live-1", fallback: "sk-live-2" },
nested: { keep: true },
// A secret buried under a non-sensitive key. A top-level-only check
// would print `accessToken` in full.
trace: {
id: "t-1",
accessToken: "sk-live-3",
deeper: { refresh_token: "sk-live-4", ok: 1 },
},
// Objects inside an array are reached too.
attempts: [{ password: "hunter2" }, { attempt: 2 }],
},
env: [
{ key: "TOKEN", value: "secret" },
{ key: "", value: "still-secret" },
@ -242,10 +255,20 @@ describe("showServerEntry / servers/show", () => {
{ key: "Authorization", value: "[redacted]" },
{ key: "X-Custom", value: "ok" },
]);
expect(sanitized.metadata).toEqual([
{ key: "Authorization", value: "[redacted]" },
{ key: "X-Custom", value: "ok" },
]);
expect(sanitized.metadata).toEqual({
Authorization: "[redacted]",
"X-Custom": "ok",
"X-Api-Key": "[redacted]",
// A structured value under a non-sensitive key survives intact...
nested: { keep: true },
// ...but a sensitive key nested inside one does not.
trace: {
id: "t-1",
accessToken: "[redacted]",
deeper: { refresh_token: "[redacted]", ok: 1 },
},
attempts: [{ password: "[redacted]" }, { attempt: 2 }],
});
expect(sanitized.env).toEqual([
{ key: "TOKEN", value: "[redacted]" },
{ key: "", value: "[redacted]" },

View file

@ -554,6 +554,7 @@ describe("--print-handoff", () => {
MCP_INSPECTOR_API_TOKEN: "tok123",
CLIENT_PORT: "16274",
MCP_SANDBOX_PORT: "16275",
MCP_APP_ORIGIN_PORT: "16278",
MCP_STORAGE_DIR: "/tmp/inspector-storage",
MCP_INSPECTOR_OAUTH_STATE_PATH: "",
},
@ -574,6 +575,9 @@ describe("--print-handoff", () => {
expect(out.deepLink).toContain("transport=http");
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
// The dedicated app origin (#2056) forwards with the other two — an app
// declaring `_meta.ui.domain` is unreachable without it.
expect(out.portForwardCmd).toContain("--tcp 16278:16278");
expect(out.oauthStatePath).toBe(
join("/tmp/inspector-storage", "oauth.json"),
);

View file

@ -14,7 +14,6 @@
"commander": "^13.1.0",
"open": "^10.2.0",
"pino": "^9.14.0",
"undici": "^8.9.0",
"zod": "^4.4.3"
},
"bin": {
@ -4440,15 +4439,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",

View file

@ -38,7 +38,6 @@
"commander": "^13.1.0",
"open": "^10.2.0",
"pino": "^9.14.0",
"undici": "^8.9.0",
"zod": "^4.4.3"
},
"devDependencies": {

View file

@ -17,6 +17,7 @@ import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { cleanRoots } from "@inspector/core/mcp/serverList.js";
import {
createProxyFetch,
createTransportNode,
loadServerEntries,
selectServerEntry,
@ -24,6 +25,8 @@ import {
parseHeaderPair,
} from "@inspector/core/mcp/node/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js";
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
import {
canonicalUrlHost,
isAllInterfacesHost,
@ -33,7 +36,6 @@ import { consumeMethodOutcome } from "./handlers/consume-outcome.js";
import { runMethod } from "./handlers/run-method.js";
import {
isOneShotMethod,
metaValueToString,
ONE_SHOT_METHODS,
type MethodArgs,
} from "./handlers/method-types.js";
@ -122,6 +124,11 @@ async function callMethod(
const environment: InspectorClientEnvironment = {
transport: createTransportNode,
// Proxy support sits at the bottom of the fetch stack so InspectorClient's
// wrappers compose over it — and so OAuth discovery/token requests, which
// also run through `environment.fetch`, are proxied too (#2067). Undefined
// when no proxy env var is set, which leaves the built-in fetch in place.
fetch: createProxyFetch(),
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
// Disarmed until the CLI-owned interactive OAuth flow runs — SDK `auth()`
@ -162,6 +169,17 @@ async function callMethod(
// the change at all: the SDK refuses `roots/list_changed` from a client
// that never declared it, which `setRoots` logged as a send failure (#1797).
roots: cleanRoots(serverSettings?.roots ?? []),
// Per-server default `_meta` from mcp.json, exactly as web (`App.tsx`) and
// the TUI pass it — the setting belongs to the server, not to the client
// that happens to read it, and `InspectorClient` only reads the option
// rather than falling back to `serverSettings.metadata` (#2093). Already a
// JSON object (#1910), so there is no pair-array flattening left to do;
// `{}` means "no defaults". `--metadata` stays per-invocation and wins on a
// key collision, since call-time keys override defaults in `mergeMeta`.
...(serverSettings?.metadata &&
Object.keys(serverSettings.metadata).length > 0 && {
defaultMetadata: serverSettings.metadata,
}),
serverSettings,
// Per-server protocol era (SEP §7.8) from mcp.json → SDK versionNegotiation.
// Absent era defaults to legacy in the InspectorClient constructor (#1626).
@ -445,6 +463,11 @@ function buildHandoff(
: canonicalUrlHost(host);
const clientPort = process.env.CLIENT_PORT || "6274";
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
// The dedicated app origin (#2056). Forwarded alongside the other two: an App
// whose UI resource declares `_meta.ui.domain` is served from this port and
// the browser reaches it DIRECTLY, so a handoff that forwards only 6274/6275
// renders that app from an unreachable origin.
const appOriginPort = process.env.MCP_APP_ORIGIN_PORT || "6278";
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
// can't satisfy the deep-link autoConnect gate.
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
@ -462,7 +485,7 @@ function buildHandoff(
return {
serverUrl: normalizedUrl,
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`,
oauthStatePath: statePath,
apiToken: apiToken ?? null,
note:
@ -474,8 +497,8 @@ function buildHandoff(
function parseKeyValuePair(
value: string,
previous: Record<string, JsonValue> = {},
): Record<string, JsonValue> {
previous: Record<string, StrictJsonValue> = {},
): Record<string, StrictJsonValue> {
const parts = value.split("=");
const key = parts[0];
const val = parts.slice(1).join("=");
@ -486,13 +509,31 @@ function parseKeyValuePair(
);
}
let parsedValue: JsonValue;
// `StrictJsonValue`: `JSON.parse` cannot produce `undefined`, and these values
// become `_meta`, which must reach the wire exactly as written (#1910).
let parsedValue: StrictJsonValue;
try {
parsedValue = JSON.parse(val) as JsonValue;
parsedValue = JSON.parse(val) as StrictJsonValue;
} catch {
// Not JSON at all — a bare word or an unquoted string. Sent as a string,
// which is what the user plainly meant.
parsedValue = val;
}
// Valid JSON syntax is not the same as sendable JSON: `1e400` parses to
// `Infinity`, which `JSON.stringify` writes as `null`. Rejecting is better
// than accepting the flag and silently transmitting a different value —
// and better than falling back to the literal string, which would also not
// be what was asked for.
if (!isSerializableJson(parsedValue)) {
// Names the key, never the value: the pair can carry a credential
// (`credentials={"accessToken":"…","n":1e400}`) and this message lands in
// stderr and CI logs.
throw new Error(
`Invalid value for "${key}": numbers must be finite (a literal like 1e400 overflows to Infinity and cannot be sent).`,
);
}
return { ...previous, [key as string]: parsedValue };
}
@ -736,8 +777,8 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
promptName?: string;
promptArgs?: Record<string, JsonValue>;
logLevel?: LoggingLevel;
metadata?: Record<string, JsonValue>;
toolMetadata?: Record<string, JsonValue>;
metadata?: Record<string, StrictJsonValue>;
toolMetadata?: Record<string, StrictJsonValue>;
cwd?: string;
transport?: "sse" | "http" | "stdio";
serverUrl?: string;
@ -999,22 +1040,12 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
promptName: options.promptName,
promptArgs: options.promptArgs,
logLevel: options.logLevel,
metadata: options.metadata
? Object.fromEntries(
Object.entries(options.metadata).map(([key, value]) => [
key,
metaValueToString(value),
]),
)
: undefined,
toolMeta: options.toolMetadata
? Object.fromEntries(
Object.entries(options.toolMetadata).map(([key, value]) => [
key,
metaValueToString(value),
]),
)
: undefined,
// `--metadata`/`--tool-metadata` values are parsed as JSON, and `_meta`
// takes any JSON — so they go through unflattened (#1910). They used to be
// squeezed through `metaValueToString`, which sent `{"a":1}` as the
// *string* `'{"a":1}'`.
metadata: options.metadata,
toolMeta: options.toolMetadata,
appInfo: options.appInfo === true,
format: options.format,
};

View file

@ -2,6 +2,7 @@ import { InspectorClient } from "@inspector/core/mcp/index.js";
import { extractAppInfo } from "@inspector/core/mcp/apps.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import type { CliAppInfo } from "./method-types.js";
import type { RequestMetadata } from "@inspector/core/mcp/types.js";
/**
* Build the CLI's app-info for a tool. Never throws — failures fold into
@ -10,7 +11,7 @@ import type { CliAppInfo } from "./method-types.js";
export async function collectAppInfo(
client: Pick<InspectorClient, "readResource">,
tool: Parameters<typeof extractAppInfo>[0],
metadata: Record<string, string> | undefined,
metadata: RequestMetadata | undefined,
): Promise<CliAppInfo> {
let base: AppInfo;
try {

View file

@ -22,7 +22,7 @@ export function withConnectTimeout(
if (settings) return { ...settings, connectionTimeout };
return {
headers: [],
metadata: [],
metadata: {},
env: [],
connectionTimeout,
requestTimeout: 0,

View file

@ -1,4 +1,5 @@
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { RequestMetadata } from "@inspector/core/mcp/types.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import type { LoggingLevel } from "@modelcontextprotocol/client";
import type { OutputFormat } from "./format-output.js";
@ -20,8 +21,8 @@ export type MethodArgs = {
logLevel?: LoggingLevel;
toolName?: string;
toolArg?: Record<string, JsonValue>;
toolMeta?: Record<string, string>;
metadata?: Record<string, string>;
toolMeta?: RequestMetadata;
metadata?: RequestMetadata;
appInfo?: boolean;
format?: OutputFormat;
/** Task id for tasks/get, tasks/cancel, tasks/result. */
@ -110,13 +111,3 @@ export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number];
export function isOneShotMethod(method: string): method is OneShotMethod {
return (ONE_SHOT_METHODS as readonly string[]).includes(method);
}
/**
* Encode a metadata / tool-metadata value for MethodArgs (string map).
* Strings pass through; objects/arrays use JSON.stringify so structure is kept
* (avoids String(obj) → "[object Object]").
*/
export function metaValueToString(value: JsonValue): string {
if (typeof value === "string") return value;
return JSON.stringify(value);
}

View file

@ -157,10 +157,10 @@ export function sanitizeServerSettings(
key: h.key,
value: isSensitiveHeader(h.key) ? REDACTED : h.value,
})),
metadata: (settings.metadata ?? []).map((m) => ({
key: m.key,
value: isSensitiveHeader(m.key) ? REDACTED : m.value,
})),
// Metadata values may be any JSON (#1910), so the redaction has to descend:
// a top-level key that is not itself sensitive can hold a nested one that
// is. See `redactMetadataValue`.
metadata: redactMetadataValue(settings.metadata ?? {}),
env: (settings.env ?? []).map((e) => ({
key: e.key,
value: REDACTED,
@ -212,6 +212,39 @@ function sanitizeInitRecord(
return out;
}
/**
* Redact secret-bearing entries anywhere inside a `_meta` payload.
*
* The pre-#1910 version only had to check the top level, because a value was
* always a string — a secret could not hide below one. Now that a value may be
* an object or an array, `{ trace: { accessToken: "…" } }` would sail through a
* top-level-only check: `trace` is not sensitive, so the whole subtree,
* `accessToken` included, would print. `servers/show` output is meant to be
* safe to paste into an issue, so the walk has to reach every key.
*
* A key matching {@link isSensitiveHeader} replaces its **entire** value rather
* than being descended into, so a sensitive key holding an object cannot leak
* through its members either. Arrays are mapped elementwise: their indices are
* not names anyone can mark sensitive, but objects inside them can be.
*
* Recursion is bounded by the payload, which came from `JSON.parse` and so is a
* finite tree with no cycles.
*/
function redactMetadataValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(redactMetadataValue);
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, member]) => [
key,
isSensitiveHeader(key) ? REDACTED : redactMetadataValue(member),
]),
);
}
return value;
}
function isSensitiveHeader(key: string): boolean {
const k = key.toLowerCase();
return (

View file

@ -20,6 +20,14 @@ export default defineConfig({
// Bundle core source; leave npm deps external.
noExternal: [/^@inspector\/core/],
external: [
// `undici` MUST stay external. It is CommonJS, so inlining it rewrites
// `import("undici")` to a relative chunk whose `require("assert")` hits
// esbuild's ESM `__require` shim and throws "Dynamic require of \"assert\"
// is not supported" — and because the specifier was rewritten at build time,
// no user-side install can ever satisfy it (#2067). It is declared in the
// ROOT manifest only, so tsup cannot infer this from a nearest-manifest
// lookup; the entry has to be explicit.
"undici",
"@napi-rs/keyring",
// Root-declared (see the repo's dependency-placement rule) and CJS, which
// is the combination that bites: tsup externalizes what the *client's*
@ -30,6 +38,12 @@ export default defineConfig({
"proper-lockfile",
"@modelcontextprotocol/client",
"@modelcontextprotocol/core",
// Root-declared and reached through `core/mcp/apps.ts`, so it must be
// external here like every other root runtime dependency (AGENTS.md). This
// client was actually inlining it — dragging the transitive v1 SDK and
// zod-to-json-schema in with it — which the #2067 guard surfaced. ESM, so it
// was not failing the way `undici` did; the rule is what it violated.
"@modelcontextprotocol/ext-apps",
"commander",
"pino",
],

View file

@ -102,7 +102,18 @@ const h = vi.hoisted(() => {
// handler for the event (the common single-server case).
type EventEntry = { client: unknown; fn: (event: unknown) => void };
const clientEvents = new Map<string, Set<EventEntry>>();
const clientInstances: Array<{ cfg?: { type?: string; url?: string } }> = [];
const clientInstances: Array<{
cfg?: { type?: string; url?: string };
// The options the mount effect built for this server. Captured so a test
// can assert what was actually handed to the client — `defaultMetadata`
// and the `environment.fetch` proxy wiring (#2067), both of which are
// invisible to a render-only assertion.
opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } };
}> = [];
// What the mocked `createProxyFetch` returns. `undefined` is the real
// behavior with no proxy env var set; a test points this at a sentinel to
// exercise the proxy-configured branch (#2067).
const proxyFetchState: { current: unknown } = { current: undefined };
const fireClientEvent = (event: string, detail?: unknown) => {
clientEvents.get(event)?.forEach((e) => e.fn({ detail }));
};
@ -127,8 +138,15 @@ const h = vi.hoisted(() => {
}
class FakeClient {
cfg: { type?: string; url?: string } | undefined;
constructor(config?: { type?: string; url?: string }) {
opts:
| { defaultMetadata?: unknown; environment?: { fetch?: unknown } }
| undefined;
constructor(
config?: { type?: string; url?: string },
opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } },
) {
this.cfg = config;
this.opts = opts;
clientInstances.push(this);
}
// Derive the transport type from the server config the client was built
@ -183,6 +201,7 @@ const h = vi.hoisted(() => {
}
return {
ctrl,
proxyFetchState,
connect,
disconnect,
openUrl,
@ -232,6 +251,12 @@ vi.mock("@inspector/core/mcp/state/index.js", () => ({
}));
vi.mock("@inspector/core/mcp/node/index.js", () => ({
createTransportNode: vi.fn(),
// Defaults to undefined — "no proxy configured", which is what the real one
// returns with no proxy env var set, so `environment.fetch` stays unset and
// the client falls back to the built-in fetch, exactly as before #2067. A
// test can point `h.proxyFetchState.current` at a sentinel to exercise the
// other branch.
createProxyFetch: vi.fn(() => h.proxyFetchState.current),
}));
vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
useInspectorClient: h.useInspectorClient,
@ -340,7 +365,7 @@ function oneEmaHttp(): Record<string, TuiServer> {
config: { type: "streamable-http", url: "http://localhost:8080/mcp" },
settings: {
requestTimeout: 0,
metadata: [],
metadata: {},
headers: [],
env: [],
roots: [],
@ -393,10 +418,10 @@ function httpWithSettings(): Record<string, TuiServer> {
config: { type: "streamable-http", url: "http://x" },
settings: {
requestTimeout: 5000,
metadata: [
{ key: "team", value: "alpha" },
{ key: " ", value: "ignored" },
],
// Object-shaped with a nested value (#1910) — the shape the TUI now
// forwards verbatim. As a pair array this would have reached the wire
// as numeric `_meta` keys.
metadata: { team: "alpha", trace: { id: "abc", hops: [1, 2] } },
oauthClientId: "cid",
oauthClientSecret: "secret",
oauthScopes: "read write",
@ -633,6 +658,7 @@ beforeEach(() => {
h.callbackStop.mockClear();
h.clientEvents.clear();
h.clientInstances.length = 0;
h.proxyFetchState.current = undefined;
h.runner.override = null;
h.clientSpies.authenticate.mockReset();
h.clientSpies.authenticate.mockResolvedValue(
@ -1114,6 +1140,13 @@ describe("App (input handling, focus, effects)", () => {
it("builds a client with saved settings (metadata, oauth, timeout)", async () => {
const r = await mount(httpWithSettings());
await expectFrame(r, "MCP Servers");
// Not just "it mounted": the saved metadata must reach the client as the
// same object, nesting intact (#1910).
const web = h.clientInstances.find((c) => c.cfg?.url === "http://x");
expect(web?.opts?.defaultMetadata).toEqual({
team: "alpha",
trace: { id: "abc", hops: [1, 2] },
});
});
it("passes top-level oauth client credentials into an http client", async () => {
@ -1608,6 +1641,27 @@ describe("App (OAuth result branches)", () => {
await expectFrame(r, "Authorization updated. Retry your action");
});
it("installs the proxy fetch as environment.fetch when a proxy is configured", async () => {
// The bottom-of-stack wiring for #2067. It is one line in App.tsx and it is
// what makes InspectorClient's own wrappers compose OVER the proxy instead
// of discarding it — delete it and TUI proxy support silently disappears
// while every other test stays green.
const sentinel: typeof fetch = async () => new Response("");
h.proxyFetchState.current = sentinel;
await mount(oneHttp());
expect(h.clientInstances.length).toBeGreaterThan(0);
expect(h.clientInstances[0]!.opts?.environment?.fetch).toBe(sentinel);
});
it("leaves environment.fetch unset when no proxy is configured", async () => {
// The default path must stay on the built-in fetch — a wrapper installed
// unconditionally would put every TUI user behind an undici fetch.
h.proxyFetchState.current = undefined;
await mount(oneHttp());
expect(h.clientInstances.length).toBeGreaterThan(0);
expect(h.clientInstances[0]!.opts?.environment?.fetch).toBeUndefined();
});
it("ignores auth lifecycle events from a non-selected server", async () => {
const r = await mount(twoHttp()); // web is selected; api is not
await press(r, ["a"]);

View file

@ -19,7 +19,7 @@ export function makeFakeServerSettings(
): InspectorServerSettings {
return {
headers: [],
metadata: [],
metadata: {},
env: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -31,7 +31,10 @@ import {
FetchRequestLogState,
StderrLogState,
} from "@inspector/core/mcp/state/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
import {
createProxyFetch,
createTransportNode,
} from "@inspector/core/mcp/node/index.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
@ -290,14 +293,14 @@ function App({
const environment: InspectorClientEnvironment = {
transport: createTransportNode,
logger: getTuiLogger(),
// Bottom of the fetch stack, so InspectorClient's wrappers compose
// over it and OAuth requests are proxied too (#2067). Undefined when
// no proxy env var is set.
fetch: createProxyFetch(),
};
const defaultMetadata = savedSettings?.metadata
? Object.fromEntries(
savedSettings.metadata
.filter((m) => m.key.trim() !== "")
.map((m) => [m.key, m.value]),
)
: undefined;
// Per-server default `_meta` is already a JSON object (#1910) — no
// pair-array flattening left to do; `{}` means "no defaults".
const defaultMetadata = savedSettings?.metadata;
const clientAuthOptions = buildRunnerClientAuthOptions(
clientConfig,
savedSettings,

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