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
2 changed files with 168 additions and 7 deletions

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();
}
});
});
});