fix(cli): apply a server's persisted mcp.json metadata

The web client and the TUI both read `savedSettings.metadata` and pass it
as `InspectorClientOptions.defaultMetadata`. The CLI never did, and
`InspectorClient` only reads the option rather than falling back to
`serverSettings.metadata` — so a per-server `metadata` entry was silently
inert for `--cli` runs.

Pass it beside the existing `roots:`, guarded on a non-empty map the same
way web and the TUI guard theirs, so `{}` still means "no defaults".
Precedence is unchanged: call-time keys override defaults in `mergeMeta`,
so `--metadata` stays per-invocation and wins on a key collision.

Covered by three catalog-driven tests asserting against the test server's
recorded wire requests. `createTestConfig` is retyped `StoredMCPServer` —
the accurate on-disk shape — since a fixture that cannot express the
per-server keys cannot cover the settings that reach the client.

Closes #2093

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 19:32:23 -04:00
commit 7da20dcfd0
5 changed files with 168 additions and 7 deletions

View file

@ -116,7 +116,7 @@ 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 `_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. |
| `--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. |

View file

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

View file

@ -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", () => {
@ -1028,4 +1033,143 @@ describe("Metadata Tests", () => {
}
});
});
describe("Per-server metadata from mcp.json (#2093)", () => {
/**
* Write a one-server catalog pointing at `url`, carrying `metadata` as the
* on-disk per-server key. Returns the catalog path; the caller deletes it.
*/
function writeCatalogWithMetadata(
url: string,
metadata: StrictJsonObject,
): string {
return createTestConfig({
mcpServers: {
"meta-server": {
type: "streamable-http",
url,
metadata,
},
},
});
}
it("applies a server's persisted metadata to every request", async () => {
// The setting belongs to the server, not to the client that reads it —
// web and the TUI already honored it, and the CLI silently did not,
// because `InspectorClient` reads `defaultMetadata` rather than falling
// back to `serverSettings.metadata`.
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
let catalogPath: string | undefined;
try {
await server.start();
catalogPath = writeCatalogWithMetadata(server.url, { tenant: "acme" });
const result = await runCli([
"--catalog",
catalogPath,
"--server",
"meta-server",
"--cli",
"--method",
"tools/list",
]);
expectCliSuccess(result);
expect(expectValidJson(result)).toHaveProperty("tools");
const toolsListRequest = server
.getRecordedRequests()
.find((r) => r.method === "tools/list");
expect(toolsListRequest).toBeDefined();
expect(toolsListRequest?.metadata).toEqual({ tenant: "acme" });
} finally {
if (catalogPath) deleteConfigFile(catalogPath);
await server.stop();
}
});
it("merges --metadata over the persisted defaults", async () => {
// `--metadata` is per-invocation and stays that way: non-colliding keys
// merge with the catalog's, and a colliding one wins (call-time keys
// override defaults in `InspectorClient.mergeMeta`).
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
let catalogPath: string | undefined;
try {
await server.start();
catalogPath = writeCatalogWithMetadata(server.url, {
tenant: "acme",
region: "eu",
});
const result = await runCli([
"--catalog",
catalogPath,
"--server",
"meta-server",
"--cli",
"--method",
"tools/list",
"--metadata",
"tenant=override",
]);
expectCliSuccess(result);
const toolsListRequest = server
.getRecordedRequests()
.find((r) => r.method === "tools/list");
expect(toolsListRequest?.metadata).toEqual({
tenant: "override",
region: "eu",
});
} finally {
if (catalogPath) deleteConfigFile(catalogPath);
await server.stop();
}
});
it("sends no _meta when the persisted metadata is empty", async () => {
// `{}` means "no defaults" — an empty map must not put a bare `_meta` on
// the wire, matching what the option-less CLI has always sent.
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
let catalogPath: string | undefined;
try {
await server.start();
catalogPath = writeCatalogWithMetadata(server.url, {});
const result = await runCli([
"--catalog",
catalogPath,
"--server",
"meta-server",
"--cli",
"--method",
"tools/list",
]);
expectCliSuccess(result);
const toolsListRequest = server
.getRecordedRequests()
.find((r) => r.method === "tools/list");
expect(toolsListRequest).toBeDefined();
expect(toolsListRequest?.metadata).toBeUndefined();
} finally {
if (catalogPath) deleteConfigFile(catalogPath);
await server.stop();
}
});
});
});

View file

@ -169,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).

View file

@ -168,7 +168,7 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w
| `protocolEra` | `"legacy"` | `"legacy"` \| `"auto"` \| `"modern"` — which protocol era to negotiate, orthogonal to the transport |
| `modernLogLevel` | `"debug"` | Per-request log level stamped on modern connections, or `"off"`. Legacy connections ignore it |
| `roots` | — | Roots advertised via the `roots` client capability; each is `{ uri, name? }` |
| `metadata` | — | Default `_meta` payload merged into every outgoing request — a JSON object; values may be any JSON except for the reserved keys noted below. Web and TUI only today; see the CLI note below |
| `metadata` | — | Default `_meta` payload merged into every outgoing request — a JSON object; values may be any JSON except for the reserved keys noted below. Sent by all three clients |
| `connectionTimeout` / `requestTimeout` | — | Timeouts in ms |
| `taskTtl` | `60000` | TTL in ms for tasks created via "Run as task" (`DEFAULT_TASK_TTL_MS`) |
| `autoRefreshOnListChanged` | `false` | Refresh lists automatically on `*/list_changed` instead of only flagging the indicator |
@ -189,7 +189,7 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w
> **A few keys are reserved and keep their own shape.** "Any JSON" holds for keys you invent, not for the ones the protocol defines. In the current SDK those are `progressToken` (a string or a **safe** integer — one JavaScript represents exactly, so nothing past `Number.MAX_SAFE_INTEGER`) and `io.modelcontextprotocol/related-task` (`{ "taskId": "<string>" }`). A reserved key whose value does not match is **dropped from the outgoing request**, with a warning naming the key — because a conforming server rejects the _entire_ request over one bad reserved member, rather than ignoring that key. Everything else in the payload is sent unchanged.
> **Not yet applied by the CLI.** The web client and the TUI both send this payload on every request. The CLI does not — it never passes the persisted value to its client, so a `metadata` entry is currently inert for `--cli` runs ([#2093](https://github.com/modelcontextprotocol/inspector/issues/2093)). Use the per-invocation `--metadata` flag there in the meantime.
> **All three clients send it.** Web, TUI, and CLI alike merge this payload into every outgoing request — the setting belongs to the server, not to the client that happens to read it. The CLI was the exception until [#2093](https://github.com/modelcontextprotocol/inspector/issues/2093): it never passed the persisted value to its client, so a `metadata` entry was silently inert for `--cli` runs. The CLI's per-invocation `--metadata` flag still applies on top, and its keys win on a collision.
> **Reading an older file.** Before #1910 this field was a `[{ "key": …, "value": … }]` pair array. That shape is still **read**, so an existing `mcp.json` keeps working unchanged; it is never written back, so the field is rewritten as an object the next time the Inspector saves the entry.