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

View file

@ -6,6 +6,10 @@ import type {
MCPServerConfig,
InspectorClientEnvironment,
} from "@inspector/core/mcp/types.js";
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
} from "@inspector/core/mcp/types.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import {
ManagedToolsState,
@ -21,6 +25,11 @@ import {
parseHeaderPair,
} from "@inspector/core/mcp/node/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import { extractAppInfo } from "@inspector/core/mcp/apps.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js";
import { parseOAuthPersistBlob } from "@inspector/core/auth/oauth-persist.js";
import { CliExitCodeError, EXIT_CODES } from "./error-handler.js";
import {
ConsoleNavigation,
MutableRedirectUrlProvider,
@ -56,6 +65,16 @@ export const validLogLevels: LoggingLevel[] = Object.values(
/** Client identity name the CLI reports to servers. */
const CLI_CLIENT_NAME = "inspector-cli";
/**
* Default connect timeout (ms) for ad-hoc server invocations. Without this an
* unreachable server (e.g. a partner edge that drops the SYN) hangs the CLI
* indefinitely; the value is generous enough for cold-start OAuth discovery
* round-trips while still failing fast on a black-holed host.
*/
export const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
type OutputFormat = "text" | "json";
type MethodArgs = {
method?: string;
promptName?: string;
@ -66,8 +85,27 @@ type MethodArgs = {
toolArg?: Record<string, JsonValue>;
toolMeta?: Record<string, string>;
metadata?: Record<string, string>;
appInfo?: boolean;
format?: OutputFormat;
};
/**
* {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure
* during the probe is reported instead of being silently swallowed (which would
* make "no CSP declared" indistinguishable from "resource unreadable").
*/
export type CliAppInfo = AppInfo & { resourceError?: string };
/**
* Discriminated outcome from {@link callMethod}'s per-method runner. Most
* methods return a `result` (with optional collected `appInfo`) for
* {@link emitResult} to format; the `tools/list --app-info` NDJSON path writes
* its lines itself and reports `emitted` so the caller skips a second write.
*/
type MethodOutcome =
| { kind: "result"; result: McpResponse; appInfo?: CliAppInfo }
| { kind: "emitted" };
async function callMethod(
serverConfig: MCPServerConfig,
serverSettings: InspectorServerSettings | undefined,
@ -120,8 +158,9 @@ async function callMethod(
null;
let managedPromptsState: ManagedPromptsState | null = null;
const runMethod = async (): Promise<McpResponse> => {
const runMethod = async (): Promise<MethodOutcome> => {
let result: McpResponse;
let appInfo: CliAppInfo | undefined;
if (args.method === "tools/list" || args.method === "tools/call") {
managedToolsState = new ManagedToolsState(inspectorClient);
@ -146,7 +185,26 @@ async function callMethod(
}
if (args.method === "tools/list") {
result = { tools: managedToolsState!.getTools() };
const tools = managedToolsState!.getTools();
if (args.appInfo) {
// NDJSON: one app-info line per tool, all on a single connection. A
// caller that wants only the App tools can `| jq -c 'select(.hasApp)'`.
// collectAppInfo never throws — a tool with a malformed `_meta.ui`
// surfaces as `{hasApp:false, resourceError}` — so one bad tool can't
// abort the whole listing. Emitted verbatim as NDJSON regardless of
// --format (the list-probe shape is fixed; --format json only reshapes
// the single-result paths).
for (const tool of tools) {
const info = await collectAppInfo(
inspectorClient,
tool,
args.metadata,
);
await awaitableLog(JSON.stringify(info) + "\n");
}
return { kind: "emitted" };
}
result = { tools };
} else if (args.method === "tools/call") {
if (!args.toolName) {
throw new Error(
@ -158,15 +216,28 @@ async function callMethod(
.getTools()
.find((t) => t.name === args.toolName);
if (!tool) {
result = {
content: [
{
type: "text" as const,
text: `Tool '${args.toolName}' not found.`,
},
],
isError: true,
};
// Distinct from `isError:true` and (for --app-info) from "tool has no
// app": the named tool does not exist on the server. Exit TOOL_ERROR
// with `code: "tool_not_found"` so a caller can tell a typo/rename
// apart from a real tool failure or a no-app probe result.
throw new CliExitCodeError(
EXIT_CODES.TOOL_ERROR,
`Tool '${args.toolName}' not found on server.`,
{ code: "tool_not_found" },
);
}
// Only collect app-info when the caller asked for it (`--app-info` or
// `--format json`); a plain text-mode `tools/call` shouldn't fail just
// because the tool's `_meta.ui.resourceUri` is malformed or its resource
// is unreadable.
if (args.appInfo || args.format === "json") {
appInfo = await collectAppInfo(inspectorClient, tool, args.metadata);
}
if (args.appInfo) {
// --app-info: probe-only — emit the app metadata and skip the tool
// call entirely. The no-app exit code is handled in emitResult.
result = { ...appInfo };
} else {
const invocation = await inspectorClient.callTool(
tool,
@ -229,6 +300,16 @@ async function callMethod(
args.metadata,
);
result = invocation.result;
} else if (args.method === "initialize") {
// Connect-only probe: emit the cached InitializeResult fields so a
// caller can read serverInfo / protocolVersion / capabilities /
// instructions without picking a list method.
result = {
serverInfo: inspectorClient.getServerInfo(),
protocolVersion: inspectorClient.getProtocolVersion(),
capabilities: inspectorClient.getCapabilities(),
instructions: inspectorClient.getInstructions(),
};
} else if (args.method === "logging/setLevel") {
if (!args.logLevel) {
throw new Error(
@ -240,11 +321,11 @@ async function callMethod(
result = {};
} else {
throw new Error(
`Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
`Unsupported method: ${args.method}. Supported methods include: initialize, tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
);
}
return result;
return { kind: "result", result, appInfo };
};
try {
@ -256,7 +337,7 @@ async function callMethod(
serverSettings,
);
const result = await withCliAuthRecoveryRetry(
const outcome = await withCliAuthRecoveryRetry(
inspectorClient,
redirectUrlProvider,
callbackUrlConfig,
@ -264,7 +345,11 @@ async function callMethod(
runMethod,
);
await awaitableLog(JSON.stringify(result, null, 2));
// The NDJSON `tools/list --app-info` path already wrote its lines; every
// other method hands its result to emitResult for format/exit handling.
if (outcome.kind === "result") {
await emitResult(outcome.result, outcome.appInfo, args);
}
} finally {
managedToolsState?.destroy();
managedResourcesState?.destroy();
@ -274,6 +359,248 @@ async function callMethod(
}
}
/**
* Write the method result (and any app-info) to stdout, honouring `--format`
* and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map.
* Extracted from `callMethod` so the format/exit handling is in one place.
*/
export async function emitResult(
result: McpResponse,
appInfo: CliAppInfo | undefined,
args: MethodArgs,
): Promise<void> {
const json = args.format === "json";
if (args.appInfo) {
const info: CliAppInfo = appInfo ?? {
hasApp: false,
toolName: args.toolName ?? "",
};
// Single-line JSON either way; --format json wraps it under an `appInfo`
// key so the envelope shape is uniform with the non-probe path.
await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n");
if (!info.hasApp) {
throw new CliExitCodeError(
EXIT_CODES.NO_APP,
`Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`,
);
}
return;
}
if (json) {
// One JSON object on stdout — `result` plus, when present, `appInfo` as a
// sibling key. No `--- MCP App Info ---` banner, so `| jq` works for App
// tools as well as plain ones.
const envelope: Record<string, unknown> = { result };
if (appInfo?.hasApp) envelope.appInfo = appInfo;
await awaitableLog(JSON.stringify(envelope) + "\n");
} else {
// Text mode emits the result only; app-info is not collected on this path
// (use `--format json` or `--app-info` to get it).
await awaitableLog(JSON.stringify(result, null, 2) + "\n");
}
// A tool that returned `isError:true` (or whose call failed) is still printed
// above so the caller sees the payload, but the process exits TOOL_ERROR so
// `&&` chains don't proceed on a failed call.
if ((result as { isError?: unknown }).isError === true) {
throw new CliExitCodeError(
EXIT_CODES.TOOL_ERROR,
`Tool '${args.toolName}' returned isError:true.`,
{ code: "tool_is_error" },
);
}
}
/**
* Build the CLI's app-info for a tool: extract the tool-side `_meta.ui` and,
* when the tool advertises a UI resource, follow it with a `resources/read` so
* the resource-side csp/permissions/domain are included.
*
* Never throws — the two failure modes both fold into a `{hasApp:false,
* resourceError}` result rather than propagating:
* - a malformed `_meta.ui.resourceUri` (extractAppInfo throws), so the
* `tools/list --app-info` NDJSON loop stays per-tool tolerant (one bad tool
* can't abort the whole listing);
* - a `resources/read` failure, since "tool says it has an app but the
* resource is unreadable" is itself a useful probe result.
*/
export async function collectAppInfo(
client: Pick<InspectorClient, "readResource">,
tool: Parameters<typeof extractAppInfo>[0],
metadata: Record<string, string> | undefined,
): Promise<CliAppInfo> {
let base: AppInfo;
try {
base = extractAppInfo(tool);
} catch (e) {
return {
hasApp: false,
toolName: tool.name,
resourceError: e instanceof Error ? e.message : String(e),
};
}
if (!base.hasApp || base.resourceUri === undefined) return base;
try {
const read = await client.readResource(base.resourceUri, metadata);
return extractAppInfo(tool, read.result);
} catch (e) {
return {
...base,
resourceError: e instanceof Error ? e.message : String(e),
};
}
}
/**
* Canonicalise a server URL the same way the web inspector does before storing
* OAuth state (`new URL().href` lowercases the host, normalises the scheme, and
* adds a trailing `/` for bare-origin URLs). The CLI must look up by the same
* key the web side wrote, so a trailing-slash or case mismatch doesn't miss a
* token that's sitting one key over. Falls back to the raw string when the URL
* can't be parsed (e.g. an ad-hoc non-URL target).
*/
export function normalizeServerUrl(serverUrl: string): string {
try {
return new URL(serverUrl).href;
} catch {
return serverUrl;
}
}
/** The stored-server map shape the CLI reads out of the OAuth state file. */
type StoredServers = Record<string, { tokens?: { access_token?: string } }>;
/**
* Read the OAuth state file directly (bypassing the Zustand store cache) so
* each call sees the current on-disk state — required for `--wait-for-auth`
* polling. Returns the `servers` map, or an empty object when the file is
* absent or unreadable. Uses the shared {@link parseOAuthPersistBlob} so both
* the plain `{servers,idpSessions}` and legacy `{state,version}` layouts are
* accepted, matching whatever the web backend wrote.
*/
async function readOAuthServers(statePath: string): Promise<StoredServers> {
const { readFile } = await import("node:fs/promises");
try {
const text = await readFile(statePath, "utf8");
const snapshot = parseOAuthPersistBlob(text);
return (snapshot?.servers as StoredServers | undefined) ?? {};
} catch {
return {};
}
}
/**
* Look up a stored access token for `serverUrl`, trying the URL-normalised key
* first (how the web store writes it) and the raw string second.
*/
function findStoredToken(
servers: StoredServers,
serverUrl: string,
): string | undefined {
const key = normalizeServerUrl(serverUrl);
return (
servers[key]?.tokens?.access_token ??
servers[serverUrl]?.tokens?.access_token
);
}
/**
* Poll the OAuth state file until a token for `serverUrl` appears (or the
* timeout elapses). Used by `--wait-for-auth` so an automated caller can hand
* off to a human for the OAuth dance and resume once the token lands. The
* lookup is normalised, so a trailing-slash mismatch between the URL the human
* opened and the one the agent passed still resolves.
*/
async function waitForStoredToken(
serverUrl: string,
statePath: string,
timeoutSec: number,
): Promise<string> {
const key = normalizeServerUrl(serverUrl);
const deadline = Date.now() + timeoutSec * 1000;
for (;;) {
const servers = await readOAuthServers(statePath);
const token = findStoredToken(servers, serverUrl);
if (token) return token;
if (Date.now() >= deadline) {
const stored = Object.keys(servers);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`--wait-for-auth timed out after ${timeoutSec}s; no stored OAuth token for ${key} in ${statePath}.` +
(stored.length > 0
? ` Stored keys: ${stored.join(", ")}.`
: " No tokens stored yet."),
{ code: "auth_wait_timeout", url: serverUrl },
);
}
await new Promise((r) => setTimeout(r, 500));
}
}
/**
* Build the JSON `--print-handoff` emits: everything an automated caller needs
* to relay to a human so they can complete OAuth in a browser and have the
* token land where the CLI will find it.
*
* NOTE: the `deepLink` query shape (serverUrl/transport/autoConnect) is the
* interim format pending the web deep-link auto-connect work (#1576); reconcile
* with that issue's canonical handoff format once it lands.
*/
function buildHandoff(serverUrl: string, statePath: string): McpResponse {
const host = process.env.HOST || "127.0.0.1";
const clientPort = process.env.CLIENT_PORT || "6274";
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
// can't satisfy the deep-link autoConnect gate.
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
// TODO(#1576): interim deep-link shape. `transport: "http"` is hardcoded even
// for SSE servers, and `autoConnect=<token>` is NOT one of the three token
// sources the web app reads (window.__INSPECTOR_API_TOKEN__ /
// ?MCP_INSPECTOR_API_TOKEN / sessionStorage — see CLAUDE.md). Reconcile both
// with #1576's canonical handoff/deep-link format once it lands.
const params = new URLSearchParams({ serverUrl, transport: "http" });
if (apiToken) params.set("autoConnect", apiToken);
return {
serverUrl: normalizeServerUrl(serverUrl),
deepLink: `http://${host}:${clientPort}/?${params.toString()}`,
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
oauthStatePath: statePath,
apiToken: apiToken ?? null,
note:
apiToken === undefined
? "MCP_INSPECTOR_API_TOKEN is not set; the deep-link autoConnect gate will reject — launch the web inspector with a known token first."
: undefined,
};
}
/**
* Apply a connection timeout to a resolved server's settings, building a
* minimal {@link InspectorServerSettings} when none came from the file. Ad-hoc
* invocations get {@link DEFAULT_CONNECT_TIMEOUT_MS} so a black-holed host
* fails fast; catalog/config invocations keep their file-level timeout unless
* `--connect-timeout` is passed explicitly.
*/
export function withConnectTimeout(
settings: InspectorServerSettings | undefined,
connectionTimeout: number | undefined,
): InspectorServerSettings | undefined {
if (connectionTimeout === undefined) return settings;
if (settings) return { ...settings, connectionTimeout };
return {
headers: [],
metadata: [],
env: [],
connectionTimeout,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
autoRefreshOnListChanged: false,
roots: [],
};
}
function parseKeyValuePair(
value: string,
previous: Record<string, JsonValue> = {},
@ -298,16 +625,23 @@ function parseKeyValuePair(
return { ...previous, [key as string]: parsedValue };
}
async function parseArgs(argv?: string[]): Promise<{
serverConfig: MCPServerConfig;
serverSettings: InspectorServerSettings | undefined;
methodArgs: MethodArgs & { method: string };
clientConfigPath?: string;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
callbackUrl?: string;
}> {
type ParseResult =
| {
shortCircuit?: undefined;
serverConfig: MCPServerConfig;
serverSettings: InspectorServerSettings | undefined;
methodArgs: MethodArgs & { method: string };
clientConfigPath?: string;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
callbackUrl?: string;
}
// Short-circuit modes (`--list-stored-auth`, `--print-handoff`) do their own
// output and need no server connection; runCli returns immediately.
| { shortCircuit: true };
async function parseArgs(argv?: string[]): Promise<ParseResult> {
const program = new Command();
// On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead
// of letting commander call process.exit(). The binary entry (index.ts) still
@ -432,6 +766,35 @@ async function parseArgs(argv?: string[]): Promise<{
parseKeyValuePair,
{},
)
.option(
"--app-info",
"Probe the tool's MCP App UI metadata (resourceUri, csp, permissions, domain) and emit it as one JSON line; exit 2 when the tool has no app. Use with --method tools/call --tool-name <name> (the tool itself is not invoked) or --method tools/list (one NDJSON line per tool).",
)
.option(
"--connect-timeout <ms>",
`Connection timeout in ms (default ${DEFAULT_CONNECT_TIMEOUT_MS} for ad-hoc --server-url / target invocations; 0 = no timeout).`,
(v: string) => {
const n = Number(v);
if (!Number.isFinite(n) || n < 0) {
throw new Error(`--connect-timeout must be a non-negative number.`);
}
return n;
},
)
.option(
"--format <format>",
"Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).",
(v: string): OutputFormat => {
if (v !== "text" && v !== "json") {
throw new Error(`--format must be 'text' or 'json'.`);
}
return v;
},
)
.option(
"--tool-args-json <json>",
'Tool arguments as a single JSON object (e.g. \'{"zip":"10001"}\'). Values are passed verbatim — no key=value coercion. Mutually exclusive with --tool-arg.',
)
.option(
"--client-config <path>",
"Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)",
@ -451,6 +814,31 @@ async function parseArgs(argv?: string[]): Promise<{
.option(
"--callback-url <url>",
`OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
)
.option(
"--use-stored-auth",
"Read the OAuth access token for --server-url from the OAuth state file (written by the web inspector) and inject it as Authorization: Bearer.",
)
.option(
"--wait-for-auth <sec>",
"Poll the OAuth state file until a token for --server-url appears (or the timeout elapses), then proceed as if --use-stored-auth were set. Use after handing off to a human to complete OAuth in a browser.",
(v: string) => {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) {
throw new Error(
`--wait-for-auth must be a positive number of seconds.`,
);
}
return n;
},
)
.option(
"--list-stored-auth",
"Print the server URLs that have a stored OAuth token (one JSON object on stdout) and exit. No server connection is made.",
)
.option(
"--print-handoff",
"Print a JSON handoff block (deepLink, portForwardCmd, oauthStatePath, apiToken) for --server-url and exit. No server connection is made.",
);
program.parse(preArgs);
@ -473,17 +861,60 @@ async function parseArgs(argv?: string[]): Promise<{
transport?: "sse" | "http" | "stdio";
serverUrl?: string;
header?: Record<string, string>;
appInfo?: boolean;
connectTimeout?: number;
format?: OutputFormat;
toolArgsJson?: string;
clientConfig?: string;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
callbackUrl?: string;
useStoredAuth?: boolean;
waitForAuth?: number;
listStoredAuth?: boolean;
printHandoff?: boolean;
};
// State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH →
// <MCP_STORAGE_DIR>/oauth.json → ~/.mcp-inspector/storage/oauth.json — the
// same file the web backend writes, so tokens are shared across surfaces.
const oauthStatePath = getStateFilePath();
// Short-circuit modes that need no server connection.
if (options.listStoredAuth) {
const servers = await readOAuthServers(oauthStatePath);
const withToken = Object.entries(servers)
.filter(([, v]) => Boolean(v.tokens?.access_token))
.map(([k]) => k);
await awaitableLog(
JSON.stringify({ oauthStatePath, storedServerUrls: withToken }) + "\n",
);
return { shortCircuit: true };
}
if (options.printHandoff) {
if (!options.serverUrl) {
throw new Error("--print-handoff requires --server-url");
}
await awaitableLog(
JSON.stringify(buildHandoff(options.serverUrl, oauthStatePath)) + "\n",
);
return { shortCircuit: true };
}
// Honour MCP_CATALOG_PATH only when no ad-hoc target is given. Applying it
// unconditionally meant a homespace that exports the env var could never run
// `--server-url …` (serverSourceConflict rejects catalog + ad-hoc).
const adHoc =
targetArgs.length > 0 ||
Boolean(options.transport) ||
Boolean(options.serverUrl?.trim());
const envCatalog = adHoc ? undefined : process.env.MCP_CATALOG_PATH;
const serverOptions = {
// `?.trim() ||` (not `??`) so an explicit empty `--catalog ""` still falls
// back to MCP_CATALOG_PATH — keeps CLI and TUI flag resolution identical.
catalogPath: options.catalog?.trim() || process.env.MCP_CATALOG_PATH,
catalogPath: options.catalog?.trim() || envCatalog,
configPath: options.config?.trim() || undefined,
target: targetArgs.length > 0 ? targetArgs : undefined,
transport: options.transport,
@ -492,16 +923,62 @@ async function parseArgs(argv?: string[]): Promise<{
env: options.e,
// `--header` is merged into the resolved server's settings (overriding any
// file-level headers); file timeouts/OAuth are preserved. See #1482.
headers: options.header,
headers: options.header as Record<string, string> | undefined,
};
if (options.waitForAuth !== undefined || options.useStoredAuth) {
if (!options.serverUrl) {
throw new Error(
`${options.waitForAuth !== undefined ? "--wait-for-auth" : "--use-stored-auth"} requires --server-url`,
);
}
// Read the OAuth state file directly so the lookup is normalised the same
// way the web inspector wrote it (`new URL().href`), and so `--wait-for-
// auth` sees fresh on-disk state on each poll. Header injection is the
// prototype path — only the access token is injected, blindly; a stale one
// surfaces as HTTP 401 → exit 3 (auth_required). Follow-up: when the stored
// `tokens.refresh_token` is present and the access token is expired, mint a
// fresh one instead of injecting the stale token.
let token: string;
if (options.waitForAuth !== undefined) {
token = await waitForStoredToken(
options.serverUrl,
oauthStatePath,
options.waitForAuth,
);
} else {
const servers = await readOAuthServers(oauthStatePath);
const found = findStoredToken(servers, options.serverUrl);
if (!found) {
const key = normalizeServerUrl(options.serverUrl);
const stored = Object.keys(servers);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`No stored OAuth token for ${key} in ${oauthStatePath}. Complete the OAuth flow in the web inspector first.` +
(stored.length > 0 ? ` Stored keys: ${stored.join(", ")}.` : ""),
{ code: "no_stored_token", url: options.serverUrl },
);
}
token = found;
}
serverOptions.headers = {
...(serverOptions.headers ?? {}),
Authorization: `Bearer ${token}`,
};
}
// Shared with the TUI: resolves the catalog/config source (or ad-hoc target),
// enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into
// per-server settings. `--server` selects one when the file has several.
const entries = await loadServerEntries(serverOptions);
const { config: serverConfig, settings: serverSettings } = selectServerEntry(
entries,
options.server,
const selected = selectServerEntry(entries, options.server);
const serverConfig = selected.config;
// Ad-hoc invocations get a default connect timeout so a black-holed host
// fails fast; catalog/config runs keep their file-level timeout unless
// `--connect-timeout` is passed explicitly.
const serverSettings = withConnectTimeout(
selected.settings,
options.connectTimeout ?? (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined),
);
if (!options.method) {
@ -510,10 +987,47 @@ async function parseArgs(argv?: string[]): Promise<{
);
}
if (
options.appInfo &&
options.method !== "tools/call" &&
options.method !== "tools/list"
) {
throw new Error(
"--app-info requires --method tools/call (with --tool-name) or --method tools/list.",
);
}
// --tool-args-json passes arguments verbatim with no key=value coercion (so
// `"012"` stays a string and nested objects work without shell escaping).
let toolArg = options.toolArg;
if (options.toolArgsJson !== undefined) {
if (toolArg && Object.keys(toolArg).length > 0) {
throw new Error(
"--tool-args-json cannot be combined with --tool-arg; pick one.",
);
}
let parsed: unknown;
try {
parsed = JSON.parse(options.toolArgsJson);
} catch (e) {
throw new Error(
`--tool-args-json is not valid JSON: ${(e as Error).message}`,
);
}
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
throw new Error("--tool-args-json must be a JSON object.");
}
toolArg = parsed as Record<string, JsonValue>;
}
const methodArgs: MethodArgs & { method: string } = {
method: options.method,
toolName: options.toolName,
toolArg: options.toolArg,
toolArg,
uri: options.uri,
promptName: options.promptName,
promptArgs: options.promptArgs,
@ -534,6 +1048,8 @@ async function parseArgs(argv?: string[]): Promise<{
]),
)
: undefined,
appInfo: options.appInfo === true,
format: options.format,
};
return {
@ -549,6 +1065,9 @@ async function parseArgs(argv?: string[]): Promise<{
}
export async function runCli(argv?: string[]): Promise<void> {
const parsed = await parseArgs(argv ?? process.argv);
// `--list-stored-auth` / `--print-handoff` already wrote their output.
if (parsed.shortCircuit) return;
const {
serverConfig,
serverSettings,
@ -558,7 +1077,7 @@ export async function runCli(argv?: string[]): Promise<void> {
clientSecret,
clientMetadataUrl,
callbackUrl,
} = await parseArgs(argv ?? process.argv);
} = parsed;
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl);
await callMethod(