inspector/clients/cli/__tests__/programmatic-ergonomics.test.ts
cliffhall b33532c024 feat: allow complex JSON values in request _meta
Closes #1910

`_meta` was typed `Record<string, string>` end to end in the Inspector —
on disk as a `{ key, value }[]` pair array, in the Server Settings form as
key/value rows, and in every request verb's per-call metadata parameter.
Nothing in the MCP spec restricts a `_meta` value to a string, and the SDK
models the field as a `z.looseObject`, so the restriction was ours alone.

Widen the whole path to `RequestMetadata` (`Record<string, JsonValue>`):

- Per-server default metadata is a JSON **object** on disk and in memory.
  The pre-#1910 pair array is still read (`normalizeStoredMetadata`) so an
  existing `mcp.json` keeps working; it is never written back, so a save
  migrates the entry.
- The Server Settings → Request Metadata section is a JSON editor
  (`JsonObjectInput`) instead of key/value rows — rows cannot express an
  object value. It keeps the typed text as the display source and emits
  only parsed JSON, the same draft split `SchemaJsonField` uses, so the box
  stays typeable; invalid text is flagged inline and not applied.
- The CLI stops flattening `--metadata` / `--tool-metadata` through
  `metaValueToString`. Those values were already JSON-parsed, so
  `trace={"id":"abc"}` used to reach the wire as the *string* `{"id":"abc"}`
  and now reaches it as an object.
- `servers/show` redaction replaces a sensitive key's whole value rather
  than a string, so a structured value cannot leak through its members.
- `progressTokenOf` narrows the reserved `progressToken` key back to
  `string | number` before it reaches the SDK; any other JSON still travels
  inside `_meta` but wires up no progress callback.

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>
2026-08-23 12:23:40 -04:00

294 lines
8.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import { runCli } from "./helpers/cli-runner.js";
import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import {
expectCliFailure,
expectCliSuccess,
expectOutputContains,
} from "./helpers/assertions.js";
describe("withConnectTimeout", () => {
const baseSettings: InspectorServerSettings = {
headers: [{ key: "X-Test", value: "1" }],
metadata: {},
env: [],
connectionTimeout: 42,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
autoRefreshOnListChanged: false,
roots: [],
};
it("returns the settings unchanged when no timeout is given", () => {
expect(withConnectTimeout(baseSettings, undefined)).toBe(baseSettings);
expect(withConnectTimeout(undefined, undefined)).toBeUndefined();
});
it("overrides an existing settings' connectionTimeout", () => {
const result = withConnectTimeout(baseSettings, 5000);
expect(result).not.toBe(baseSettings);
expect(result?.connectionTimeout).toBe(5000);
// Other fields are preserved.
expect(result?.headers).toEqual(baseSettings.headers);
});
it("builds a minimal settings object when none was provided", () => {
const result = withConnectTimeout(undefined, DEFAULT_CONNECT_TIMEOUT_MS);
expect(result?.connectionTimeout).toBe(DEFAULT_CONNECT_TIMEOUT_MS);
expect(result?.headers).toEqual([]);
expect(result?.requestTimeout).toBe(0);
});
});
/**
* Covers the programmatic-ergonomics flags from #1573: `--method initialize`,
* `--tool-args-json`, `--connect-timeout`, and the MCP_CATALOG_PATH ad-hoc
* gate. All connect to the bundled stdio test server.
*/
describe("--method initialize", () => {
it("connects and emits the cached InitializeResult fields", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([command, ...args, "--method", "initialize"]);
expectCliSuccess(result);
const json = JSON.parse(result.stdout) as {
serverInfo: { name: string };
protocolVersion: string;
capabilities: Record<string, unknown>;
};
expect(json.serverInfo.name).toBeTruthy();
expect(typeof json.protocolVersion).toBe("string");
expect(typeof json.capabilities).toBe("object");
});
});
describe("--tool-args-json", () => {
it("passes the JSON object verbatim (string values stay strings)", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-args-json",
JSON.stringify({ message: "012" }),
]);
expectCliSuccess(result);
// echo returns its message; with --tool-arg message=012 the value would
// coerce to the number 12 (and fail the string schema), but
// --tool-args-json preserves the string "012".
expect(result.stdout).toContain("012");
});
it("rejects --tool-args-json combined with --tool-arg", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"x=1",
"--tool-args-json",
"{}",
]);
expectCliFailure(result);
expectOutputContains(result, "cannot be combined with --tool-arg");
});
it("rejects malformed JSON", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-args-json",
"{not json}",
]);
expectCliFailure(result);
expectOutputContains(result, "not valid JSON");
});
it("rejects a non-object value", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-args-json",
"[1,2]",
]);
expectCliFailure(result);
expectOutputContains(result, "must be a JSON object");
});
});
describe("CLI --relogin flag conflicts", () => {
it("rejects --relogin with --stored-auth-only", async () => {
const result = await runCli([
"--relogin",
"--stored-auth-only",
"--server-url",
"https://example.com/mcp",
"--method",
"tools/list",
]);
expectCliFailure(result);
expect(result.stderr).toMatch(
/--relogin cannot be combined with --stored-auth-only/,
);
});
it("rejects --relogin with --use-stored-auth", async () => {
const result = await runCli([
"--relogin",
"--use-stored-auth",
"--server-url",
"https://example.com/mcp",
"--method",
"tools/list",
]);
expectCliFailure(result);
expect(result.stderr).toMatch(/--use-stored-auth/);
});
it("rejects --relogin with catalog-only methods", async () => {
const result = await runCli(["--relogin", "--method", "servers/list"]);
expectCliFailure(result);
expect(result.stderr).toMatch(/servers\/list or servers\/show/);
});
it("rejects --relogin with --list-stored-auth", async () => {
const result = await runCli(["--relogin", "--list-stored-auth"]);
expectCliFailure(result);
expect(result.stderr).toMatch(/--list-stored-auth or --print-handoff/);
});
it("rejects --relogin for stdio servers", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--relogin",
"--method",
"tools/list",
]);
expectCliFailure(result);
expect(result.stderr).toMatch(/HTTP\/SSE server URL/);
});
it("accepts --relogin and clears stored auth before connect", async () => {
// Unreachable port: proves --relogin runs (clears store) then fails connect.
const result = await runCli([
"--relogin",
"--server-url",
"http://127.0.0.1:1/mcp",
"--transport",
"http",
"--connect-timeout",
"200",
"--method",
"initialize",
]);
expectCliFailure(result);
});
});
describe("CLI --stored-auth-only wiring", () => {
it("accepts the flag and fails connect without interactive OAuth prompts", async () => {
// Unreachable host: proves the flag is accepted through parse → callMethod
// and does not hang on an interactive OAuth prompt.
const result = await runCli([
"--stored-auth-only",
"--server-url",
"http://127.0.0.1:1/mcp",
"--transport",
"http",
"--connect-timeout",
"200",
"--method",
"initialize",
]);
expectCliFailure(result);
expect(result.stderr).not.toMatch(/Please navigate to:/);
expect(result.stderr).not.toMatch(/Proceed with step-up/);
});
});
describe("MCP_CATALOG_PATH with an ad-hoc target", () => {
it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([command, ...args, "--method", "tools/list"], {
env: { MCP_CATALOG_PATH: "/no/such/catalog.json" },
});
expectCliSuccess(result);
});
});
describe("--connect-timeout", () => {
it("accepts a numeric value and connects within it", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--connect-timeout",
"5000",
"--method",
"tools/list",
]);
expectCliSuccess(result);
});
it("accepts 0 (no timeout) and connects", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--connect-timeout",
"0",
"--method",
"tools/list",
]);
expectCliSuccess(result);
});
it("rejects a negative value", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--connect-timeout",
"-1",
"--method",
"tools/list",
]);
expectCliFailure(result);
expectOutputContains(result, "non-negative number");
});
it("rejects a non-numeric value", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--connect-timeout",
"soon",
"--method",
"tools/list",
]);
expectCliFailure(result);
expectOutputContains(result, "non-negative number");
});
});