Merge pull request #2094 from modelcontextprotocol/v2/feat/1910-complex-request-metadata

feat: allow complex JSON values in request _meta
This commit is contained in:
Cliff Hall 2026-08-23 20:45:07 -04:00 • committed by GitHub
commit b44c090144
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 2466 additions and 606 deletions

View file

@ -110,15 +110,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. |
| `--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

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

View file

@ -373,16 +373,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 +429,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 +457,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 +820,7 @@ describe("Metadata Tests", () => {
);
expect(toolsListRequest).toBeDefined();
expect(toolsListRequest?.metadata).toEqual({
integration_test: "true",
integration_test: true,
test_phase: "all_methods",
});
} finally {
@ -866,8 +867,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 +880,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 +979,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: "🚀🎉✨",

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

@ -24,6 +24,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 +35,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";
@ -474,8 +475,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 +487,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 +755,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 +1018,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

@ -102,7 +102,13 @@ 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`
// in particular, which is otherwise invisible to a render-only assertion.
opts?: { defaultMetadata?: unknown };
}> = [];
const fireClientEvent = (event: string, detail?: unknown) => {
clientEvents.get(event)?.forEach((e) => e.fn({ detail }));
};
@ -127,8 +133,13 @@ const h = vi.hoisted(() => {
}
class FakeClient {
cfg: { type?: string; url?: string } | undefined;
constructor(config?: { type?: string; url?: string }) {
opts: { defaultMetadata?: unknown } | undefined;
constructor(
config?: { type?: string; url?: string },
opts?: { defaultMetadata?: unknown },
) {
this.cfg = config;
this.opts = opts;
clientInstances.push(this);
}
// Derive the transport type from the server config the client was built
@ -340,7 +351,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 +404,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",
@ -1114,6 +1125,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 () => {

View file

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

View file

@ -291,13 +291,9 @@ function App({
transport: createTransportNode,
logger: getTuiLogger(),
};
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,

View file

@ -1,14 +1,11 @@
import type { Preview } from "@storybook/react-vite";
import {
MantineProvider,
useMantineColorScheme,
type CSSVariablesResolver,
} from "@mantine/core";
import { MantineProvider, useMantineColorScheme } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "../src/App.css";
import { theme } from "../src/theme/theme";
import { cssVariablesResolver } from "../src/theme/cssVariables";
import { useEffect } from "react";
// eslint-disable-next-line react-refresh/only-export-components
@ -28,14 +25,6 @@ function ColorSchemeWrapper({
return <>{children}</>;
}
const resolver: CSSVariablesResolver = () => ({
variables: {},
light: {},
dark: {
"--mantine-color-body": "var(--mantine-color-dark-9)",
},
});
const preview: Preview = {
globalTypes: {
colorScheme: {
@ -61,7 +50,7 @@ const preview: Preview = {
<MantineProvider
theme={theme}
defaultColorScheme="light"
cssVariablesResolver={resolver}
cssVariablesResolver={cssVariablesResolver}
>
<ColorSchemeWrapper
colorScheme={context.globals.colorScheme ?? "light"}

View file

@ -6,10 +6,10 @@ This README covers what's specific to the web client. For the repo-wide picture
## Two halves: `src/` (browser) and `server/` (Node)
| Path | Runs in | Purpose |
| --- | --- | --- |
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
| Path | Runs in | Purpose |
| --------- | ------- | ------------------------------------------------------------- |
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
The `server/` directory holds the Node-only backend:
@ -47,12 +47,12 @@ Two artifacts come out, both of which ship in the published package:
Components live under `src/components/` in four layers, smallest to largest:
| Layer | Count | What it is |
| --- | --- | --- |
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
| Layer | Count | What it is |
| ----------- | ----- | ------------------------------------------------------------------------------ |
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
Every screen and element has a `*.stories.tsx` (see [Storybook](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors.
@ -74,23 +74,23 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage
The Apps screen exposes a small, stable set of `data-testid` / `data-*` attributes so an automated driver (deep-link auto-open, CI review harness) can `waitForSelector` on a deterministic signal instead of sleeping. Treat these as a public contract — drivers depend on them staying stable:
| Attribute | Where | Meaning |
| --- | --- | --- |
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
| `data-testid="open-app"` | Open App button | Launches the selected app. |
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
| Attribute | Where | Meaning |
| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
| `data-testid="open-app"` | Open App button | Launches the selected app. |
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
App-rendered **elicitations** (#1854) render through the same `AppRenderer` but outside the Apps screen — one modal per request, from `AppElicitationHost` — and carry their own pair:
| Attribute | Where | Meaning |
| --- | --- | --- |
| Attribute | Where | Meaning |
| ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `data-testid="app-elicitation"` | the elicitation modal | One per in-flight app-rendered elicitation. **Absent** means the request was answered by the native elicitation form instead, which is what a driver asserts to prove the negotiation gate held. |
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
`scripts/smoke-web-elicitation.mjs` drives both halves against the public fixture (`test-servers/configs/app-elicitation-http.json` and its `-native-` sibling).
@ -104,20 +104,20 @@ A driver (launcher, CLI `--print-handoff`, CI review harness) can reach a **conn
http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token>
```
| Param | Meaning |
| --- | --- |
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
| Param | Meaning |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
| `autoConnect` | **CSRF gate.** Must equal the per-launch `MCP_INSPECTOR_API_TOKEN`. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing `?MCP_INSPECTOR_API_TOKEN=` param. Without a match the link is ignored. |
The deep link upserts a stable `deep-link` catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the `AppShell.Header` as a machine-readable contract, so a driver can `waitForSelector` and read the failure reason without scraping a transient toast:
| Attribute | Where | Meaning |
| --- | --- | --- |
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
| Attribute | Where | Meaning |
| --------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
### Landing on a rendered app
@ -127,11 +127,11 @@ Three further params extend the deep link to pre-select — and optionally auto-
…&openApp=<toolName>&appArgs=<base64url(JSON)>&autoOpen=<token>
```
| Param | Meaning |
| --- | --- |
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
| Param | Meaning |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically.
@ -139,15 +139,29 @@ The app-side render lifecycle is observable through the [MCP Apps screen automat
Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme<Name>` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider.
**`cssVariables.ts` is the third piece, beside the component files and `App.css`.** It holds overrides for the CSS variables `MantineProvider` injects at runtime, which `App.css` cannot reach: the provider appends its generated `<style>` after the stylesheet imports, so a `:root` rule there loses on source order at equal specificity. `cssVariablesResolver` is the supported seam. It is passed at **all three** `MantineProvider` sites — the app (`main.tsx`), the Storybook preview, and `renderWithMantine` — so the running app, the stories, and the tests cannot disagree about a token's value. It currently corrects `--mantine-color-error`, whose Mantine defaults fail WCAG AA in both schemes at the size input error text renders.
## Code editing (`JsonObjectInput`)
Payloads whose _values_ may be arbitrary JSON — `_meta` is the case that forced it ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)) — are edited with **Ace** (`react-ace` + `ace-builds`, declared in this client because they render React) rather than the key/value rows used for headers and env, which cannot express an object value. Ace brings code folding, brace auto-closing, and per-line error annotation from its JSON worker.
Three integration details are load-bearing:
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations.
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden.
- **The label and error are wired to Ace's hidden textarea by hand.** `Input.Wrapper` associates a _Mantine_ input through context; Ace renders its own DOM, so the id, `aria-invalid` and `aria-describedby` are set on the textarea in an effect.
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts`; real keyboard behaviour lives in the Storybook play functions, which run in Chromium.
## Testing
Tests run under three Vitest **projects** (configured in `vite.config.ts`), each in the right environment:
| Project | Env | Scope | Script |
| --- | --- | --- | --- |
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
| Project | Env | Scope | Script |
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------ | -------------------------- |
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
- `npm test` runs the fast **unit** project (happy-dom). `test:watch` for the loop.
- **Integration** tests run in a real Node env (no happy-dom, 30s timeouts) and spawn `test-servers/build/test-server-stdio.js` as a subprocess, so `pretest`/the coverage script build the test servers first (`test-servers:build`).

View file

@ -16,6 +16,7 @@
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"@napi-rs/keyring": "^1.3.0",
"ace-builds": "^1.44.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"chokidar": "^4.0.3",
@ -25,6 +26,7 @@
"papaparse": "^5.5.4",
"pino": "^9.14.0",
"react": "^19.2.4",
"react-ace": "^15.0.0",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
@ -4383,6 +4385,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/ace-builds": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz",
"integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@ -5061,6 +5069,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
"license": "Apache-2.0"
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -5487,6 +5501,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -8294,6 +8317,22 @@
"node": ">=0.10.0"
}
},
"node_modules/react-ace": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
"integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
"license": "MIT",
"dependencies": {
"ace-builds": "^1.36.3",
"diff-match-patch": "^1.0.5",
"fast-equals": "^5.3.3",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-docgen": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz",

View file

@ -42,6 +42,7 @@
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"@napi-rs/keyring": "^1.3.0",
"ace-builds": "^1.44.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"chokidar": "^4.0.3",
@ -51,6 +52,7 @@
"papaparse": "^5.5.4",
"pino": "^9.14.0",
"react": "^19.2.4",
"react-ace": "^15.0.0",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",

View file

@ -73,6 +73,16 @@
ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */
--inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4);
/* ── Code editor (Ace) ─────────────────────────────────── */
/* Ace's own `github` themes fail WCAG AA on the gutter: `#AAA` on the light
gutter's `#e8e8e8` is 1.89:1, and `#7388b5` on the dark gutter's `#24292e`
is 4.13:1 — both under 4.5:1 at the 12px the line numbers render. The
numbers are `aria-hidden`, which exempts nothing here: contrast is a
concern for sighted users, and axe flags them accordingly.
`gray-7` is 5.82:1 on the light gutter; the dark block lifts it to
`gray-5`, 4.88:1 on the dark one. */
--inspector-code-gutter-text: var(--mantine-color-gray-7);
/* ── Connection status ─────────────────────────────────── */
--inspector-status-connected: var(--mantine-color-green-6);
--inspector-status-connecting: var(--mantine-color-yellow-6);
@ -161,6 +171,9 @@
schemes, which sits too dark on charcoal — lift them one to two steps so
they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */
/* Code editor (Ace) — see the note in `:root`. */
--inspector-code-gutter-text: var(--mantine-color-gray-5);
/* Status */
--inspector-status-connected: var(--mantine-color-green-4);
--inspector-status-connecting: var(--mantine-color-yellow-4);
@ -593,3 +606,12 @@ body.resizing-col {
user-select: none;
cursor: col-resize;
}
/* Ace renders its own DOM, so its gutter can only be reached by selector — it
is not a Mantine component with a theme entry. The doubled class raises
specificity above Ace's own `.ace-github .ace_gutter`, which is injected into
<head> at runtime and therefore wins on source order otherwise. */
.ace_editor.ace-github .ace_gutter,
.ace_editor.ace-github-dark .ace_gutter {
color: var(--inspector-code-gutter-text);
}

View file

@ -1638,7 +1638,7 @@ const settingsWithRoots = (
): InspectorServerSettings => ({
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,

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