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:
commit
baf4fe954f
97 changed files with 9048 additions and 1271 deletions
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function makeFakeServerSettings(
|
|||
): InspectorServerSettings {
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
describe("withConnectTimeout", () => {
|
||||
const baseSettings: InspectorServerSettings = {
|
||||
headers: [{ key: "X-Test", value: "1" }],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 42,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -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]" },
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
|
|
|
|||
10
clients/cli/package-lock.json
generated
10
clients/cli/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function withConnectTimeout(
|
|||
if (settings) return { ...settings, connectionTimeout };
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function makeFakeServerSettings(
|
|||
): InspectorServerSettings {
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue