cli: Wave 3 — programmatic review path (rollup of #1573–#1575) (#1655)

* feat(cli): programmatic ergonomics — initialize, tool-args-json, connect-timeout, env-var fixes

Adds the #1573 programmatic-ergonomics surface to the CLI, re-implemented
informed by PR #1510 and adapted to the v2/main OAuth-wired cli.ts:

- `--method initialize` — connect-only probe emitting
  `{serverInfo, protocolVersion, capabilities, instructions}`.
- `--tool-args-json '{"k":"v"}'` — tool args passed verbatim as JSON (no
  key=value coercion); mutually exclusive with `--tool-arg`, with clear
  errors for combined use, malformed JSON, and non-object values.
- `--connect-timeout <ms>` — default 15000 for ad-hoc `--server-url`/target
  runs, file-level timeout otherwise; `0` disables. Backed by the exported
  `withConnectTimeout()` helper.
- MCP_CATALOG_PATH is ignored when an ad-hoc target is given, so a shell that
  exports it can still run one-off ad-hoc invocations.
- MCP_STORAGE_DIR is honored by core `getStateFilePath` (`<dir>/oauth.json`),
  below the per-file MCP_INSPECTOR_OAUTH_STATE_PATH override.

Tests: new programmatic-ergonomics.test.ts (CLI, in-process runCli + direct
withConnectTimeout unit tests) and getStateFilePath resolution cases in the
web storage integration suite. Per-file coverage gate stays ≥90.
README: documents the new flags and env-var semantics.

Closes #1573

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* feat(cli): --app-info probe + --format json envelope

Adds the #1574 app-probing and machine-readable output surface, re-implemented
informed by PR #1510 and adapted to the v2/main OAuth-wired cli.ts.

- `--method tools/call --tool-name <t> --app-info` — prints one JSON line
  describing the tool's app posture (hasApp, resourceUri, csp, permissions,
  domain, …) WITHOUT invoking the tool; exit 0 when it has an app, 2 (no_app)
  when it doesn't. A missing tool exits 5 (tool_not_found) — distinct from the
  no-app exit so a typo isn't mistaken for "no app".
- `--method tools/list --app-info` — one NDJSON line per tool over a single
  connection (`| jq -c 'select(.hasApp)'`).
- `--format json` — a single stdout envelope (`{result}` plus `{appInfo}` as a
  sibling key for App tools), no banner, so any method pipes into jq.
- A tools/call returning isError:true now exits 5 (tool_is_error) after
  printing its payload, so && chains stop on a failed call.

Implementation: `collectAppInfo()` (core `extractAppInfo` + a tolerated
resource read that reports `resourceError`), an NDJSON loop in tools/list, and
a single `emitResult()` that owns format + exit-code handling. `runMethod` now
returns a `MethodOutcome` discriminated union so the NDJSON path can signal
"already emitted".

Tests: app-info.test.ts (exit-0/2/5 + NDJSON + no-invoke), format-json.test.ts
(envelope shapes + bad --format), emit-result.test.ts (direct unit coverage of
the isError→exit-5 and resourceError branches via exported emitResult/
collectAppInfo). Updated the tools.test "nonexistent tool" case to the new
exit-5 tool_not_found contract. Per-file coverage ≥90 on all four dimensions
(cli.ts 100% stmts/funcs/lines). README documents the flags, NDJSON shape, and
exit semantics.

Closes #1574

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* feat(cli): stored-auth suite — --use-stored-auth, --wait-for-auth, --list-stored-auth, --print-handoff

Adds the #1575 web→CLI OAuth handoff surface, re-implemented informed by PR
#1510 and adapted to v2/main.

- `--use-stored-auth` — read the shared oauth.json and inject
  `Authorization: Bearer <token>` for --server-url; exit 3 (no_stored_token),
  listing stored keys, when none matches.
- `--wait-for-auth <sec>` — poll oauth.json (500ms) until a token for
  --server-url appears, then proceed; timeout → exit 3 (auth_wait_timeout).
- `--list-stored-auth` — print `{oauthStatePath, storedServerUrls}` and exit
  (short-circuit, no connect).
- `--print-handoff` — emit `{serverUrl, deepLink, portForwardCmd,
  oauthStatePath, apiToken, note}` for --server-url and exit.

Adaptations vs. the reference:
- State-path resolution reuses core `getStateFilePath()` (the
  MCP_INSPECTOR_OAUTH_STATE_PATH → <MCP_STORAGE_DIR>/oauth.json → default
  precedence added in #1573) instead of a CLI-local resolver.
- The state file is read through the shared isomorphic
  `parseOAuthPersistBlob`, so both the plain `{servers,idpSessions}` layout the
  web backend writes and the legacy `{state,version}` envelope are accepted.
- `parseArgs` now returns a ParseResult union so the short-circuit modes return
  without a server connection.

Blind injection: the token is passed as-is; a stale one surfaces as HTTP 401 →
exit 3. Documented in the README along with all four flags, the state-path
precedence, and a follow-up note that --print-handoff's deepLink shape is
interim pending #1576's canonical handoff format.

Tests: stored-auth.test.ts drives injection, --header coexistence, missing
--server-url, no-token exit-3, URL-normalisation matching, MCP_STORAGE_DIR
resolution, listing, handoff (+note), wait-for-auth polling/timeout, and
normalizeServerUrl units — all against a real in-process HTTP test server.
Per-file coverage ≥90 on all four dimensions (cli.ts 100% stmts/funcs/lines).

Closes #1575

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* docs(core): scope MCP_STORAGE_DIR comment in getStateFilePath to the OAuth backend

Address review of PR #1651: the doc comment claimed MCP_STORAGE_DIR was
honoured "across the app, so setting it once points every persist backend at
the same directory", but the branch only relocates oauth.json. Narrow the
comment to state it is OAuth-backend-only (client.json/mcp.json still resolve
from getDefaultStorageDir → HOME) and record why the resolution lives here
rather than in getDefaultStorageDir (the default-path constant is evaluated at
module load, so a runtime-set MCP_STORAGE_DIR — as the CLI stored-auth /
--wait-for-auth flows set it — must be read at call time). Comment-only; no
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review(cli): per-tool-tolerant app-info listing + drop dead helper (PR #1653)

Address review of PR #1653:

- collectAppInfo now never throws: a malformed `_meta.ui.resourceUri`
  (extractAppInfo throws) folds into `{hasApp:false, resourceError}` just like a
  resources/read failure. This makes `tools/list --app-info` per-tool tolerant —
  one bad tool no longer aborts the whole NDJSON listing with a generic exit-1.
  Guarding inside collectAppInfo (vs. a loop-local try/catch) keeps the branch
  unit-testable; added an emit-result.test.ts case for the malformed-URI path.
- Documented that `tools/list --app-info` always emits NDJSON regardless of
  `--format` (the per-tool list shape is fixed; --format json only reshapes the
  single-result paths) — in both the code comment and the README.
- Removed the now-unused `expectJsonError` helper (its only caller moved to the
  exit-5 tool_not_found contract).

Coverage stays ≥90 on all four dimensions (cli.ts 100% stmts/funcs/lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review(cli): stored-auth follow-ups — TODO(#1576), wait-for-auth guard test, docs (PR #1655)

Address review of PR #1655:

- Added an explicit TODO(#1576) on buildHandoff's deepLink: `transport:"http"`
  is hardcoded even for SSE, and `autoConnect=<token>` is not one of the three
  token sources the web app reads (per CLAUDE.md) — both to be reconciled with
  #1576's canonical handoff format.
- Added a test for `--wait-for-auth` without `--server-url`, covering the
  previously-untested `--wait-for-auth` arm of the shared missing-server-url
  guard's message ternary.
- Documented that `--list-stored-auth` / `--print-handoff` are mutually
  exclusive short-circuit modes (list takes precedence) and that a stored
  refresh_token is not yet used (noted as a follow-up in code + README).

No behavior change; coverage stays ≥90 on all four dimensions (cli.ts 100%
stmts/funcs/lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-11 22:14:23 -04:00 • committed by GitHub
commit a029e42a19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1670 additions and 55 deletions

View file

@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import { runCli } from "./helpers/cli-runner.js";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
/**
* The default stdio test server advertises exactly one MCP App tool
* (`mcp_app_demo` + its `mcp_app_demo_widget` UI resource). These cover both
* the positive probe (csp/permissions/exit-0) and the negative path
* (`{hasApp:false}` + exit 2), plus the exit-5 tool-not-found distinction and
* the NDJSON `tools/list --app-info` shape. The `extractAppInfo` unit is
* covered in the web core suite; here we exercise the CLI wiring end-to-end.
*/
describe("--app-info", () => {
it("rejects --app-info on a method other than tools/call or tools/list", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"resources/list",
"--app-info",
]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
"--app-info requires --method tools/call (with --tool-name) or --method tools/list",
);
});
it("emits NDJSON (one app-info line per tool) on --method tools/list --app-info", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/list",
"--app-info",
]);
expect(result.exitCode).toBe(0);
const lines = result.stdout.trim().split("\n");
expect(lines.length).toBeGreaterThan(1);
const infos = lines.map(
(l) => JSON.parse(l) as { hasApp: boolean; toolName: string },
);
// Every line is a valid app-info object with a toolName.
expect(infos.every((i) => typeof i.toolName === "string")).toBe(true);
// The fixture's `mcp_app_demo` is the (only) App tool.
const demo = infos.find((i) => i.toolName === "mcp_app_demo");
expect(demo?.hasApp).toBe(true);
expect(infos.filter((i) => i.hasApp).length).toBe(1);
});
it("emits {hasApp:false} as one JSON line and exits 2 for a non-App tool", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--app-info",
]);
expect(result.exitCode).toBe(2);
const line = result.stdout.trim().split("\n")[0];
const info = JSON.parse(line) as { hasApp: boolean; toolName: string };
expect(info).toEqual({ hasApp: false, toolName: "echo" });
expect(result.stderr).toContain("has no MCP App UI resource");
});
it("exits 5 (TOOL_ERROR, code:tool_not_found) when the tool is not found — distinct from the no-app exit-2", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"no-such-tool",
"--app-info",
]);
expect(result.exitCode).toBe(5);
expect(result.stdout).toBe("");
const envelope = JSON.parse(result.stderr.trim()) as {
error: { code: string };
};
expect(envelope.error.code).toBe("tool_not_found");
});
it("emits the resource-side csp/permissions and exits 0 for an App tool", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"mcp_app_demo",
"--app-info",
]);
expect(result.exitCode).toBe(0);
const info = JSON.parse(result.stdout.trim().split("\n")[0]) as {
hasApp: boolean;
toolName: string;
resourceUri: string;
csp: unknown;
permissions: unknown;
prefersBorder: boolean;
resourceMimeType: string;
};
expect(info.hasApp).toBe(true);
expect(info.toolName).toBe("mcp_app_demo");
expect(info.resourceUri).toBe("ui://demo/widget.html");
expect(info.csp).toEqual({ connectDomains: [], resourceDomains: [] });
expect(info.permissions).toEqual({ clipboard: false });
expect(info.prefersBorder).toBe(true);
expect(info.resourceMimeType).toBe("text/html");
});
it("does not collect app-info on a plain text-mode tools/call", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"mcp_app_demo",
"--tool-arg",
"title=hello",
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).not.toContain("--- MCP App Info ---");
expect(result.stdout).not.toContain("hasApp");
// stdout is a single JSON value (the tool result) so `| jq` works.
expect(() => JSON.parse(result.stdout)).not.toThrow();
});
it("does not invoke the tool when --app-info is set", async () => {
// get_sum requires numeric a/b args; without --app-info this would fail
// with a tool error. With --app-info the tool is never called, so the
// only outcome is the no-app exit-2.
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"get_sum",
"--app-info",
]);
expect(result.exitCode).toBe(2);
expect(result.output).not.toContain("isError");
});
});

View file

@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { emitResult, collectAppInfo } from "../src/cli.js";
import { CliExitCodeError } from "../src/error-handler.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
/**
* Direct unit coverage for the two output-side branches that the end-to-end
* CLI tests can't reach with the default stdio fixture: a tool that returns
* `isError:true` (exit 5), and a UI-resource read that fails during an
* `--app-info` probe (`resourceError`). Both functions are exported from
* `cli.ts` purely for this test, mirroring the `withConnectTimeout` pattern.
*/
describe("emitResult", () => {
let stdout: string;
let originalWrite: typeof process.stdout.write;
beforeEach(() => {
stdout = "";
originalWrite = process.stdout.write;
process.stdout.write = ((chunk: unknown, ...rest: unknown[]): boolean => {
stdout += typeof chunk === "string" ? chunk : String(chunk);
const cb = rest.find((r) => typeof r === "function") as
| (() => void)
| undefined;
cb?.();
return true;
}) as typeof process.stdout.write;
});
afterEach(() => {
process.stdout.write = originalWrite;
});
it("throws TOOL_ERROR (code:tool_is_error) when the result has isError:true", async () => {
const promise = emitResult({ isError: true }, undefined, {
toolName: "boom",
});
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
await promise.catch((e: CliExitCodeError) => {
expect(e.exitCode).toBe(5);
expect(e.envelope?.code).toBe("tool_is_error");
});
// The payload is still printed before the throw.
expect(stdout).toContain("isError");
});
it("does not throw for a successful result (text mode)", async () => {
await expect(
emitResult({ ok: true }, undefined, { format: "text" }),
).resolves.toBeUndefined();
expect(JSON.parse(stdout.trim())).toEqual({ ok: true });
});
it("wraps result + appInfo under a json envelope", async () => {
await emitResult(
{ value: 1 },
{ hasApp: true, toolName: "t", resourceUri: "ui://x" },
{ format: "json" },
);
expect(JSON.parse(stdout.trim())).toEqual({
result: { value: 1 },
appInfo: { hasApp: true, toolName: "t", resourceUri: "ui://x" },
});
});
});
describe("collectAppInfo", () => {
it("returns base info for a non-App tool without reading a resource", async () => {
const client = {
readResource: vi.fn(),
} as unknown as Pick<InspectorClient, "readResource">;
const info = await collectAppInfo(
client,
{ name: "plain", inputSchema: { type: "object" as const } },
undefined,
);
expect(info).toEqual({ hasApp: false, toolName: "plain" });
expect(
(client.readResource as ReturnType<typeof vi.fn>).mock.calls.length,
).toBe(0);
});
it("captures resourceError when the UI resource read fails", async () => {
const client = {
readResource: vi.fn().mockRejectedValue(new Error("unreadable")),
} as unknown as Pick<InspectorClient, "readResource">;
const tool = {
name: "app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "ui://app/widget.html" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.hasApp).toBe(true);
expect(info.resourceUri).toBe("ui://app/widget.html");
expect(info.resourceError).toBe("unreadable");
});
it("stringifies a non-Error rejection into resourceError", async () => {
const client = {
readResource: vi.fn().mockRejectedValue("string failure"),
} as unknown as Pick<InspectorClient, "readResource">;
const tool = {
name: "app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "ui://app/widget.html" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.resourceError).toBe("string failure");
});
it("folds a malformed _meta.ui.resourceUri into {hasApp:false, resourceError} instead of throwing", async () => {
// extractAppInfo throws when the advertised UI URI is not a `ui://` string;
// collectAppInfo must tolerate that so the tools/list --app-info NDJSON loop
// stays per-tool robust. readResource must never be reached here.
const readResource = vi.fn();
const client = { readResource } as unknown as Pick<
InspectorClient,
"readResource"
>;
const tool = {
name: "bad-app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "http://not-a-ui-uri" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.hasApp).toBe(false);
expect(info.toolName).toBe("bad-app");
expect(typeof info.resourceError).toBe("string");
expect(info.resourceError!.length).toBeGreaterThan(0);
expect(readResource.mock.calls.length).toBe(0);
});
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import { runCli } from "./helpers/cli-runner.js";
import {
expectCliFailure,
expectCliSuccess,
expectOutputContains,
} from "./helpers/assertions.js";
/**
* Covers the `--format json` envelope from #1574: a single JSON object on
* stdout (`result`, plus `appInfo` as a sibling key for App tools), no banner.
*/
describe("--format json", () => {
it("wraps tools/list output in a single {result} envelope", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/list",
"--format",
"json",
]);
expectCliSuccess(result);
expect(result.stdout.trim().split("\n").length).toBe(1);
const env = JSON.parse(result.stdout) as { result: { tools: unknown[] } };
expect(Array.isArray(env.result.tools)).toBe(true);
});
it("emits {result, appInfo} as one JSON object for an App tool (no banner)", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"mcp_app_demo",
"--tool-arg",
"title=hello",
"--format",
"json",
]);
expectCliSuccess(result);
expect(result.stdout).not.toContain("--- MCP App Info ---");
const env = JSON.parse(result.stdout) as {
result: unknown;
appInfo: { hasApp: boolean; resourceUri: string };
};
expect(env.result).toBeTruthy();
expect(env.appInfo.hasApp).toBe(true);
expect(env.appInfo.resourceUri).toBe("ui://demo/widget.html");
});
it("omits appInfo for a non-App tool", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message=hi",
"--format",
"json",
]);
expectCliSuccess(result);
const env = JSON.parse(result.stdout) as {
result: unknown;
appInfo?: unknown;
};
expect(env.result).toBeTruthy();
expect(env.appInfo).toBeUndefined();
});
it("wraps --app-info output under {appInfo} and exits 2 for a non-App tool", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--app-info",
"--format",
"json",
]);
expect(result.exitCode).toBe(2);
const env = JSON.parse(result.stdout.trim()) as {
appInfo: { hasApp: boolean };
};
expect(env.appInfo.hasApp).toBe(false);
});
it("rejects an unknown --format value", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/list",
"--format",
"yaml",
]);
expectCliFailure(result);
expectOutputContains(result, "--format must be 'text' or 'json'");
});
});

View file

@ -43,15 +43,6 @@ export function expectValidJson(result: CliResult) {
return JSON.parse(result.stdout);
}
/**
* Assert that output contains JSON with error flag
*/
export function expectJsonError(result: CliResult) {
const json = expectValidJson(result);
expect(json.isError).toBe(true);
return json;
}
/**
* Assert that output contains expected JSON structure
*/

View file

@ -0,0 +1,202 @@
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("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");
});
});

View file

@ -0,0 +1,380 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { runCli } from "./helpers/cli-runner.js";
import { expectCliFailure, expectCliSuccess } from "./helpers/assertions.js";
import { normalizeServerUrl } from "../src/cli.js";
import {
createTestServerHttp,
createEchoTool,
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
/**
* Writes an oauth.json fixture in the plain `{ servers, idpSessions }` layout
* the web backend persists (also accepted: the legacy `{ state, version }`
* envelope). The CLI reads it through the shared `parseOAuthPersistBlob`, so
* both sides agree on the format and the URL-normalised server key.
*/
function writeOAuthFixture(servers: Record<string, unknown>): string {
const dir = mkdtempSync(join(tmpdir(), "inspector-cli-stored-auth-"));
const file = join(dir, "oauth.json");
writeFileSync(file, JSON.stringify({ servers, idpSessions: {} }), "utf8");
return file;
}
describe("normalizeServerUrl", () => {
it("canonicalises a URL via new URL().href (lowercases scheme/host)", () => {
expect(normalizeServerUrl("HTTP://Example.COM/Mcp")).toBe(
"http://example.com/Mcp",
);
});
it("returns the raw string when the value is not a parseable URL", () => {
expect(normalizeServerUrl("not a url")).toBe("not a url");
});
});
describe("--use-stored-auth", () => {
let server: ReturnType<typeof createTestServerHttp>;
let serverUrl: string;
let fixturePath: string;
const TOKEN = "stored-access-token-abc";
beforeAll(async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
serverUrl = server.url;
fixturePath = writeOAuthFixture({
[serverUrl]: { tokens: { access_token: TOKEN, token_type: "Bearer" } },
});
});
afterAll(async () => {
await server.stop();
rmSync(fixturePath, { force: true });
});
it("injects the stored token as Authorization: Bearer on the outgoing request", async () => {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--use-stored-auth",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
);
expectCliSuccess(result);
const recorded = server.getRecordedRequests();
expect(recorded.length).toBeGreaterThan(0);
const last = recorded[recorded.length - 1]!;
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
});
it("merges with --header (explicit headers + stored auth coexist)", async () => {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--use-stored-auth",
"--header",
"X-Trace: abc",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
);
expectCliSuccess(result);
const last = server.getRecordedRequests().at(-1)!;
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
expect(last.headers?.["x-trace"]).toBe("abc");
});
it("errors clearly when --server-url is missing", async () => {
const result = await runCli(
["--use-stored-auth", "--method", "tools/list"],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
);
expectCliFailure(result);
expect(result.stderr).toContain("--use-stored-auth requires --server-url");
});
it("errors clearly when --wait-for-auth is set without --server-url", async () => {
// Exercises the `--wait-for-auth` arm of the shared missing-server-url
// guard's message ternary (the --use-stored-auth arm is covered above).
const result = await runCli(
["--wait-for-auth", "5", "--method", "tools/list"],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixturePath } },
);
expectCliFailure(result);
expect(result.stderr).toContain("--wait-for-auth requires --server-url");
});
it("errors with exit 3 (AUTH_REQUIRED) and lists stored keys when no token matches", async () => {
const other = writeOAuthFixture({
"https://other.example/mcp": {
tokens: { access_token: "x", token_type: "Bearer" },
},
});
try {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--use-stored-auth",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: other } },
);
expect(result.exitCode).toBe(3);
const env = JSON.parse(result.stderr.trim()) as {
error: { code: string; message: string };
};
expect(env.error.code).toBe("no_stored_token");
expect(env.error.message).toContain("No stored OAuth token");
expect(env.error.message).toContain("https://other.example/mcp");
} finally {
rmSync(other, { force: true });
}
});
it("matches a stored key even when --server-url differs by URL normalisation", async () => {
// Store under the normalised form; pass the upper-cased scheme on the
// command line. new URL().href lowercases the scheme, so the lookup still
// resolves.
const normalised = normalizeServerUrl(serverUrl);
const upper = serverUrl.replace("http://", "HTTP://");
const fixture = writeOAuthFixture({
[normalised]: { tokens: { access_token: TOKEN, token_type: "Bearer" } },
});
try {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
upper,
"--use-stored-auth",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } },
);
expectCliSuccess(result);
} finally {
rmSync(fixture, { force: true });
}
});
it("honours MCP_STORAGE_DIR when MCP_INSPECTOR_OAUTH_STATE_PATH is unset", async () => {
const dir = dirname(fixturePath);
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--use-stored-auth",
"--method",
"tools/list",
],
{ env: { MCP_STORAGE_DIR: dir } },
);
expectCliSuccess(result);
const last = server.getRecordedRequests().at(-1)!;
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
});
});
describe("--list-stored-auth", () => {
it("prints the stored server URLs and the resolved state path", async () => {
const fixture = writeOAuthFixture({
"https://a.example/mcp": {
tokens: { access_token: "t1", token_type: "Bearer" },
},
"https://b.example/mcp": { tokens: {} },
});
try {
const result = await runCli(["--list-stored-auth"], {
env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture },
});
expectCliSuccess(result);
const out = JSON.parse(result.stdout) as {
oauthStatePath: string;
storedServerUrls: string[];
};
expect(out.oauthStatePath).toBe(fixture);
expect(out.storedServerUrls).toEqual(["https://a.example/mcp"]);
} finally {
rmSync(fixture, { force: true });
}
});
it("emits an empty list when the state file is absent", async () => {
const result = await runCli(["--list-stored-auth"], {
env: { MCP_INSPECTOR_OAUTH_STATE_PATH: "/no/such/file.json" },
});
expectCliSuccess(result);
const out = JSON.parse(result.stdout) as { storedServerUrls: string[] };
expect(out.storedServerUrls).toEqual([]);
});
});
describe("--print-handoff", () => {
it("emits a JSON handoff block with deepLink, port-forward command, and the resolved state path", async () => {
const result = await runCli(
["--print-handoff", "--server-url", "https://x.example/mcp"],
{
env: {
MCP_INSPECTOR_API_TOKEN: "tok123",
CLIENT_PORT: "16274",
MCP_SANDBOX_PORT: "16275",
MCP_STORAGE_DIR: "/tmp/inspector-storage",
MCP_INSPECTOR_OAUTH_STATE_PATH: "",
},
},
);
expectCliSuccess(result);
const out = JSON.parse(result.stdout) as {
serverUrl: string;
deepLink: string;
portForwardCmd: string;
oauthStatePath: string;
apiToken: string;
};
expect(out.serverUrl).toBe("https://x.example/mcp");
expect(out.deepLink).toContain("autoConnect=tok123");
expect(out.deepLink).toContain("serverUrl=https%3A%2F%2Fx.example%2Fmcp");
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
expect(out.oauthStatePath).toBe(
join("/tmp/inspector-storage", "oauth.json"),
);
expect(out.apiToken).toBe("tok123");
});
it("includes a note when MCP_INSPECTOR_API_TOKEN is unset", async () => {
const result = await runCli(
["--print-handoff", "--server-url", "https://x.example/mcp"],
{ env: { MCP_INSPECTOR_API_TOKEN: "" } },
);
expectCliSuccess(result);
const out = JSON.parse(result.stdout) as {
apiToken: string | null;
note?: string;
};
expect(out.apiToken).toBeNull();
expect(out.note).toContain("MCP_INSPECTOR_API_TOKEN is not set");
});
it("requires --server-url", async () => {
const result = await runCli(["--print-handoff"]);
expectCliFailure(result);
expect(result.stderr).toContain("--print-handoff requires --server-url");
});
});
describe("--wait-for-auth", () => {
let server: ReturnType<typeof createTestServerHttp>;
let serverUrl: string;
beforeAll(async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
await server.start();
serverUrl = server.url;
});
afterAll(async () => {
await server.stop();
});
it("polls until the token appears, then proceeds with it", async () => {
const dir = mkdtempSync(join(tmpdir(), "inspector-cli-wait-"));
const file = join(dir, "oauth.json");
// Write the token after a short delay so the first poll misses.
setTimeout(() => {
writeFileSync(
file,
JSON.stringify({
servers: {
[normalizeServerUrl(serverUrl)]: {
tokens: { access_token: "waited-tok", token_type: "Bearer" },
},
},
idpSessions: {},
}),
"utf8",
);
}, 200);
try {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--wait-for-auth",
"5",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } },
);
expectCliSuccess(result);
const last = server.getRecordedRequests().at(-1)!;
expect(last.headers?.authorization).toBe("Bearer waited-tok");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("times out with exit 3 (AUTH_REQUIRED) when no token appears", async () => {
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--wait-for-auth",
"1",
"--method",
"tools/list",
],
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: "/no/such/file.json" } },
);
expect(result.exitCode).toBe(3);
const env = JSON.parse(result.stderr.trim()) as {
error: { code: string };
};
expect(env.error.code).toBe("auth_wait_timeout");
});
it("rejects a non-positive timeout", async () => {
const result = await runCli([
"--server-url",
"https://x.example/mcp",
"--wait-for-auth",
"0",
"--method",
"tools/list",
]);
expectCliFailure(result);
expect(result.stderr).toContain("positive number of seconds");
});
});

View file

@ -4,7 +4,6 @@ import {
expectCliSuccess,
expectCliFailure,
expectValidJson,
expectJsonError,
} from "./helpers/assertions.js";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
@ -378,8 +377,15 @@ describe("Tool Tests", () => {
"message=test",
]);
// CLI returns exit code 0 but includes isError: true in JSON (server returns error)
expectJsonError(result);
// A tool that does not exist on the server exits TOOL_ERROR (5) with a
// stable `tool_not_found` code, distinct from a tool that ran and
// returned isError:true. The error envelope is on stderr; stdout is empty.
expect(result.exitCode).toBe(5);
expect(result.stdout).toBe("");
const envelope = JSON.parse(result.stderr.trim()) as {
error: { code: string };
};
expect(envelope.error.code).toBe("tool_not_found");
});
it("should fail when tool name is missing", async () => {