# MCP Inspector CLI Client CLI for the Inspector: connect, run a `--method`, disconnect. Invoked as `mcp-inspector --cli`. ## Running the CLI You can run the CLI via `npx`: ```bash npx @modelcontextprotocol/inspector --cli node build/index.js ``` Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` to read catalog entries, and `servers/add` / `servers/edit` / `servers/remove` to change them, without connecting). > Coming from the v1 CLI? See the [v1 → v2 migration guide](../../docs/v1-to-v2-migration.md) — every v1 flag still exists, but exit codes, argument ordering, and the `--` separator changed. ### Examples **Basic usage** ```bash npx @modelcontextprotocol/inspector --cli node build/index.js ``` **With a configuration file** ```bash npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver ``` **List available tools** ```bash npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list ``` **Call a specific tool** ```bash npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2 ``` **Call a tool with JSON arguments** ```bash npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg 'options={"format": "json", "max_tokens": 100}' ``` **List available resources** ```bash npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list ``` **List available prompts** ```bash npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list ``` ### Remote Servers You can also connect to remote MCP servers using HTTP or SSE transports. **Connect to a remote MCP server (transport inferred from a `/mcp` or `/sse` path)** ```bash npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com/mcp --method tools/list ``` There is no default transport: a URL whose path ends in neither `/mcp` nor `/sse` needs an explicit `--transport http` or `--transport sse`, as below. A target is either a positional command/URL or `--server-url`, never both, and `--transport stdio` cannot be used with a URL — either combination is refused rather than resolved by precedence. **Connect with Streamable HTTP transport** ```bash npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list ``` **Pass custom headers** ```bash npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key" ``` When a server is loaded from a `--catalog`/`--config` file, its per-server settings (headers, connection/request timeouts, OAuth, and `roots`) are applied to the connection — the same resolution the TUI uses. A `--header` flag overrides the file's headers for that run while leaving the file's timeouts and OAuth in place. The file is the only durable way to give a run its roots: there is no roots flag, and `--method roots/set` applies only to that one short-lived connection. Roots configured for a server (the same field the web UI's Server Settings writes) are advertised at connect, so a server that asks for `roots/list` — `@modelcontextprotocol/server-filesystem` does, to learn its allowed directories — gets them. **Environment-variable semantics.** `MCP_CATALOG_PATH` is honored only when no ad-hoc target is given (positional command, `--server-url`, or `--transport`) — so a shell that exports it can still run one-off ad-hoc invocations without hitting the catalog/ad-hoc conflict. `MCP_STORAGE_DIR` sets the storage directory used by the OAuth persist backend (`/oauth.json`); the per-file `MCP_INSPECTOR_OAUTH_STATE_PATH` override still takes precedence over it. Every variable the CLI reads is listed in [Environment variables](../../docs/environment-variables.md). ### HTTP proxy support Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to every Node client — CLI, TUI, and the web backend — with no inspector-specific flag. It also covers **OAuth discovery and token requests**, which run through the same fetch. Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (declared in the **root** manifest only; `^8.x` requires Node `>= 22.19.0`, the Inspector's supported floor). It is imported lazily on the first request and only when a proxy variable is set, so runs without a proxy configured pay no cost. **Both halves of the pair come from userland undici — its `fetch` _and_ its `EnvHttpProxyAgent` ([#2067](https://github.com/modelcontextprotocol/inspector/issues/2067)).** The obvious-looking alternative is to hand the agent to Node's built-in `fetch` as a `dispatcher`, which is what v2.0.0–2.3.0 did. That couples two _different copies_ of undici at the dispatcher handler interface, and that interface is not stable across majors: Node 22 embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8 agent handed a built-in undici 7 handler is rejected outright — `fetch failed: invalid onRequestStart method` — and the request never leaves the process. Keeping both sides inside one copy is what makes proxying work unchanged from the Node 22.19 floor through Node 26. (Node's own `NODE_USE_ENV_PROXY` is not a substitute: it is unsupported at our 22.19 engine floor.) Because undici's `Response` is a different class from `globalThis.Response`, the proxied fetch re-wraps each response as a genuine global `Response`, streaming preserved. Without that, `res instanceof Response` is `false` for callers that test it — including the MCP SDK, whose OAuth error formatter would degrade every message to `Raw body: [object Response]`. `undici` is declared **only** in the root `package.json`, and every client's tsup config lists it as `external`. Both halves matter: tsup auto-externalizes what the _nearest_ manifest declares, so without the explicit entry the web and TUI bundles inlined it — and a CommonJS package inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` the first time it is used. `npm run verify:bundle-externals` is the durable guard. ### Shell completion `--completion ` prints a completion script for `mcp-inspector` on stdout and exits without connecting to anything. The first word completes the launcher's mode flags (`--cli`, `--web`, `--tui`); after `--cli` it completes every CLI flag, the `--method` names (`tools/list`, `tools/call`, `servers/list`, …) and the finite values of `--transport`, `--log-level`, `--format` and `--protocol-era`. Path flags (`--catalog`, `--config`, `--cwd`, `--client-config`) and the stdio target command fall back to file completion. ```bash # bash — current shell, or persist it source <(mcp-inspector --cli --completion bash) mcp-inspector --cli --completion bash > ~/.local/share/bash-completion/completions/mcp-inspector # zsh — current shell (after compinit), or save it on your $fpath source <(mcp-inspector --cli --completion zsh) mcp-inspector --cli --completion zsh > "${fpath[1]}/_mcp-inspector" # fish mcp-inspector --cli --completion fish > ~/.config/fish/completions/mcp-inspector.fish ``` The flag list is read from the CLI's own commander definition when the script is generated, so it cannot drift from `--help`; regenerate the script after upgrading to pick up new flags. Only `--cli` mode is completed — web and TUI flags are not. ## Options ### MCP server (which server to connect to) Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc run negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. ### CLI-specific (what to invoke) | Option | Description | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--method ` | MCP method to invoke. Supports `initialize` (connect-only probe → `{serverInfo, protocolVersion, capabilities, instructions}`), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel`, `skills/list`, `skills/get`, `resources/directory/read`, plus catalog-only `servers/list` / `servers/show` and `servers/add` / `servers/edit` / `servers/remove` (no MCP connect; see [Editing the server catalog](#editing-the-server-catalog)). Stream / session-only methods (e.g. `logging/tail`) are rejected. | | `--rename ` | New name for the entry, with `--method servers/edit` only. Secrets stored under the old name move with it. | | `--tool-name ` | Tool name (for `tools/call`). | | `--tool-arg ` | Tool argument; repeat for multiple. Use `key='{"json":true}'` for JSON. Values are coerced (JSON-parsed, so `count=1` becomes a number). | | `--tool-args-json ` | Tool arguments as a single JSON object (e.g. `'{"zip":"10001"}'`). Passed verbatim — no `key=value` coercion, so `"012"` stays a string. Mutually exclusive with `--tool-arg`. | | `--uri ` | Resource URI (`resources/read`), directory URI (`resources/directory/read`), or skill URI (`skills/get`). | | `--cursor ` | Opaque pagination cursor for `resources/directory/read` — pass back the `nextCursor` from the previous page. The listing is not recursive and pages are not aggregated: SEP-2640 gives the cursor to the client, and descending is the caller's job. | | `--prompt-name ` | Prompt name (for `prompts/get`). | | `--prompt-args ` | Prompt arguments; repeat for multiple. | | `--log-level ` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). | | `--metadata ` | 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 ` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. | | `--connect-timeout ` | 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 `connectionTimeout` for `--catalog`/`--config` runs — `30000` when the file sets none. `0` disables the timeout. | | `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-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. | | `--advertise-apps` | Advertise the MCP Apps UI extension (`io.modelcontextprotocol/ui`) at `initialize`. Off by default, because the CLI cannot render an App and a server decides whether to return one from that advertisement. Set it when a server only exposes its App tools to a client that claims App support — typically alongside `--app-info`. | | `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). | | `--verify` | With `--method skills/list` or `--method skills/get`: run the SEP-2640 conformance, digest and frontmatter checks over the skills returned, emit one JSON report per skill on stdout, and exit `7` if any fails. See [Skill verification](#skill-verification---verify). | | `--require-digests` | With `--verify`: exit `9` when a skill advertises no digests (`resources: "dynamic"`), instead of reporting it `unverifiable` and exiting `0`. See [Skill verification](#skill-verification---verify). | | `--skill-catalog-max-skills ` | With `--verify` (required; rejected without it, since it would bound nothing): the skills catalog budget, the most skills whose files one run reads (default `256`). Positive integer; anything else is rejected before connecting. Overrides the server's [`skillCatalogMaxSkills`](../../docs/mcp-server-configuration.md#inspector-specific-per-server-fields) from the catalog/config file — the same setting the web client exposes per server in Server Settings. Skills past the budget are reported `incomplete` (exit `8`). | | `--skill-catalog-max-bytes ` | With `--verify` (required): the skills catalog budget, the most bytes one run reads across all skills (default `67108864`, 64 MiB). Positive integer. Overrides the server's [`skillCatalogMaxBytes`](../../docs/mcp-server-configuration.md#inspector-specific-per-server-fields). | | `--format ` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. | | `-q`, `--quiet` | Print only the result payload on stdout, or the error envelope on stderr on failure. Drops status lines, warnings, advisory summaries and a stdio server's own stderr; OAuth prompts a human must answer still print. See [Quiet output](#quiet-output---quiet). | | `--output ` | Write the result to a file **instead of** stdout. The parent directory must exist; an existing file is replaced. See [Saving a result to a file](#saving-a-result-to-a-file---output). | | `--output-format ` | Encoding of the `--output` file. `json` (default) is the whole result, pretty-printed. `raw` is the result's text, or the decoded bytes of its single image/audio/blob block; `tools/call` and `resources/read` only. | | `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. | | `--no-revoke` | With `--relogin`, skip the [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) revocation request that would otherwise end the grant at the authorization server when the local state is deleted. The per-server `oauth.revokeOnClear` setting is the persistent form of the same opt-out; either one is enough to skip it. See [Revoking on `--relogin`](#revoking-on---relogin). | | `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. | | `--completion ` | Print a `bash`, `zsh` or `fish` completion script and exit (no server connection). See [Shell completion](#shell-completion). | #### Revoking on `--relogin` `--relogin` deletes this server's stored OAuth state so the next connect cannot silently reuse it. By default it now also **revokes the grant at the authorization server**, per [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) ([#2144](https://github.com/modelcontextprotocol/inspector/issues/2144)) — otherwise the delete is invisible to the AS, and the access token, plus the refresh token when one was issued, stay valid there until they expire on their own. The request is built from the stored state before the delete and sent after it, so the local delete never waits on the network. That matters because the OAuth store is shared: holding this process's view of it across a five-second request would let another CLI or TUI write a fresh grant that this one then erased. The request names the refresh token when there is one: RFC 7009 §2.1 asks the authorization server to invalidate the access tokens issued under the same grant, so a single request covers both. It is best-effort and never changes the exit code. An authorization server that advertises no `revocation_endpoint` gets no request at all; a network error, a non-2xx, or the short timeout prints a one-line warning on stderr and the local delete proceeds either way. Turn it off per run with `--no-revoke`, or per server with `oauth.revokeOnClear: false` in the catalog — either is enough, and neither can turn it on for the other. Disconnecting while still holding live tokens is a case worth reproducing when the server under test is the thing being debugged. ```bash mcp-inspector --cli --server-url https://example.com/mcp --relogin --method tools/list mcp-inspector --cli --server-url https://example.com/mcp --relogin --no-revoke --method tools/list ``` `servers/show` redacts secret-bearing fields (`env` values, sensitive headers, sensitive `settings.metadata` keys whose whole value is replaced whether or not it is structured, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues. #### Editing the server catalog `servers/add`, `servers/edit` and `servers/remove` write the same catalog file the web and TUI clients use — `--catalog `, else `MCP_CATALOG_PATH`, else `~/.mcp-inspector/mcp.json` — through the web backend's own `/api/servers` write path, run in-process. So the file comes out exactly as the web UI would write it: the atomic write, name validation, and the split of secret values (stdio `env` values) out of `mcp.json` into the secret store (OS keychain, or the [fallback store](../../docs/secret-storage.md)). A web backend that is already running picks the change up through its file watcher. The server entry is named with `--server ` and described with the same flags an ad-hoc run takes: a positional command or URL, `--server-url`, `--transport`, `-e`, `--cwd`, `--header`, `--protocol-era`. ```bash # Add a stdio server (its -e values go to the secret store, not mcp.json) and an HTTP one. mcp-inspector --cli node build/index.js --method servers/add --server my-server -e API_KEY=… mcp-inspector --cli --method servers/add --server remote --server-url https://example.com/mcp --header "X-Team: a" # Edit: -e merges into the env and --cwd replaces it; a new command/URL replaces the # transport; --header replaces the headers; --protocol-era sets the era; --rename renames. # Anything not named (OAuth, timeouts, metadata, roots) is kept. mcp-inspector --cli --method servers/edit --server my-server -e OTHER=1 --rename my-server-2 # Remove the entry and its stored secrets. mcp-inspector --cli --method servers/remove --server remote ``` Each prints `{ ok, action, server, catalog }` (plus `previousName` on a rename); `--format json` wraps it in `{ "result": … }`. `--config` is refused, since it names a read-only session file. `servers/add` fails on a name that already exists, and `servers/edit` / `servers/remove` fail on one that does not. `servers/remove` rejects the entry-describing flags (a command/URL, `--transport`, `-e`, `--cwd`, `--header`, `--protocol-era`, `--rename`), and `servers/edit` with nothing to change is an error. When the secret store is in-memory only (a container with no keychain and nothing durable mounted), a write that supplies `-e` values is refused rather than losing them when the CLI exits, and so is `--rename`, since this process cannot see secrets held in another process's memory — set `MCP_INSPECTOR_SECRET_STORE=file` to keep them. #### Quiet output (`--quiet`) `-q` / `--quiet` reduces a run to its result: the payload on stdout on success, and the single-line [error envelope](#exit-codes--error-envelopes) on stderr on failure. It composes with `--format`: `--format` shapes stdout, and `--quiet` strips stderr of everything non-essential (the exceptions are in the table below). ```bash mcp-inspector --cli node build/index.js -q --method tools/list | jq '.tools[].name' ``` What it suppresses: | Output | Under `--quiet` | | ------------------------------------------------------------------------------ | --------------- | | A stdio server's own stderr (startup banners, logs) | Dropped | | `Schema portability: … Re-run with --strict for details.` (`tools/list`) | Dropped | | The `--verify` one-line summary | Dropped — a failing run's envelope carries the same text | | `Authorization complete.` / `Authorization complete. Retrying…` | Dropped | | `Warning: could not revoke the OAuth grant …` (`--relogin`) | Dropped | | Advisory warnings from shared Inspector code: the `[mcp-inspector] …` secret-store notice (keychain fallback, `memory` caveat), ignored `roots` / OAuth-endpoint settings, lock and persistence trouble | Dropped (all of `console.warn` is muted for the run). See [secret storage](../../docs/secret-storage.md) for what the store notice would have said. | | The result payload / NDJSON on stdout | Kept | | The error envelope on a non-zero exit | Kept | | The `--strict` report | Kept — you asked for it, and it is the detail behind exit `6` | | The OAuth authorization URL, the step-up `[y/N]` prompt, "open it by hand" | Kept — interactive login cannot finish without them | So a quiet run that needs an interactive login still shows what it must; for a run that must never prompt, combine `--quiet` with `--stored-auth-only`. ⚠️ Dropping the server's stderr and the advisory warnings also drops their explanation when something goes wrong. The CLI still exits non-zero with an envelope, but if the reason is not obvious, re-run without `--quiet`. #### App probing (`--app-info`) and machine-readable output (`--format json`) `--app-info` inspects a tool's [MCP App](https://modelcontextprotocol.io) UI posture **without calling the tool**, so a pipeline can decide whether to open a browser before touching one: ```bash # Probe one tool. Exits 0 (has app) or 2 (no_app). One JSON line on stdout. mcp-inspector --cli --method tools/call --tool-name my_tool --app-info # → {"hasApp":true,"toolName":"my_tool","resourceUri":"ui://…","csp":{…},"permissions":{…},"prefersBorder":true,"resourceMimeType":"text/html"} # Probe every tool at once — NDJSON, one line per tool, single connection. mcp-inspector --cli --method tools/list --app-info | jq -c 'select(.hasApp)' ``` The CLI does **not** advertise the MCP Apps UI extension by default, since it cannot render an App. A server that registers its App tools only for a client that advertises Apps support will therefore show no app to a bare probe; add `--advertise-apps` to claim that support for the probe: ```bash mcp-inspector --cli --method tools/list --app-info --advertise-apps ``` Exit semantics: a tool that **has** an app exits `0`; one with **no** app exits `2` (`no_app`); a **missing** tool exits `5` (`tool_not_found`) — distinct so a typo isn't mistaken for "no app". A probe failure (an unreadable UI resource, or a malformed `_meta.ui.resourceUri`) is tolerated and reported in a `resourceError` field rather than aborting — so in `tools/list --app-info` one bad tool never kills the rest of the listing. `--format json` wraps any method's output in a single stdout envelope with no banners, so App tools and plain tools both pipe cleanly into `jq`: ```bash mcp-inspector --cli --method tools/call --tool-name my_app_tool --format json # → {"result":{…tool result…},"appInfo":{"hasApp":true,"resourceUri":"ui://…",…}} ``` > `tools/list --app-info` always emits NDJSON (one raw app-info object per line) **regardless of `--format`** — the per-tool list shape is fixed. `--format json` only reshapes the single-result paths (`tools/call`, `tools/list` without `--app-info`, etc.) into the `{result[, appInfo]}` envelope. A `tools/call` that returns `isError:true` still prints its payload but exits `5` (`tool_is_error`) so `&&` chains don't proceed on a failed call. #### Saving a result to a file (`--output`) `--output ` writes the method's result to a file instead of stdout, so a script can archive or diff results without shell redirection ([#2431](https://github.com/modelcontextprotocol/inspector/issues/2431)). `--output-format` picks how the file is encoded: | `--output-format` | File contents | | ----------------- | ------------- | | `json` (default) | The whole result, pretty-printed with two-space indentation — the same shape the web client's exports download. | | `raw` | The result's own payload: the text of every text-bearing block (text blocks and embedded text resources), joined by newlines. A result with no text and exactly **one** binary block (image, audio, or resource blob) is written as that block's decoded bytes, so an image tool saves as a viewable file. Anything else has no single raw form and fails with `output_not_raw`. Only `tools/call` and `resources/read` have a raw form. | ```bash # Archive the whole result as JSON. mcp-inspector --cli --method tools/call --tool-name echo --tool-arg message=hi --output echo.json # Save just the text a tool returned. mcp-inspector --cli --method tools/call --tool-name summarize --output summary.txt --output-format raw # Save an image tool's output as the image itself. mcp-inspector --cli --method tools/call --tool-name render_chart --output chart.png --output-format raw ``` The flag is `--output-format` rather than a new `--format` value because `--format` already shapes **stdout**, and the two compose: in text mode stdout stays empty and a one-line `Wrote N bytes (json) to ` confirmation goes to stderr (suppressed by `-q`, like any other status line); under `--format json` stdout still carries exactly one envelope, with `output` in place of `result` — `{"output":{"path":"echo.json","format":"json","bytes":123}}`, plus `appInfo` / `schemaFindings` when they would otherwise appear. Exit codes are unchanged: a `tools/call` that returns `isError:true` is still written and still exits `5`. A file that cannot be written (missing directory, no permission) exits `1` with envelope code `output_write_failed`. `--output` is rejected where there is no single result to write — `--app-info`, `--verify`, `--list-stored-auth`, `--print-handoff`, and `servers/list` / `servers/show` — and `--output-format` is rejected without `--output`, rather than either being silently ignored. #### Schema portability (`--strict`) A tool schema can be perfectly legal JSON Schema and still be refused by the client the server is meant to run against. `--strict` (with `--method tools/list`) names those constructs — path, what is wrong, and a concrete fix — on **stderr**, and exits `6` if any is error-severity: ```bash mcp-inspector --cli --method tools/list --strict ``` The complete **report** against the `unportable-schemas-http.json` showcase — one block per finding, then the summary. On a non-zero exit the shared error handler adds one more stderr line after this, the [`ErrorEnvelope`](#exit-codes--error-envelopes) (`{"error":{"code":"schema_unportable",…}}`): ```text Error: tool "get_temp" Path: outputSchema.properties.data Issue: Bare `true` used where a schema object is expected. Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, not an equivalent rewrite: `true` accepts any JSON value at all. Warning: tool "echo" Path: inputSchema.properties.show_ids Issue: `type` is an array (["null","boolean"]). The array form is legal JSON Schema, but several MCP clients read `type` as a single string and either reject the tool or drop the constraint. Suggestion: Split it into `anyOf` branches, each with a single `type` — `{"anyOf": [{"type": "null"}, {"type": "boolean"}]}`. (Making the property optional instead is a different contract: absent is not the same as `null`.) Warning: tool "echo" Path: inputSchema.properties.opts Issue: Schema carries no validation keyword at all, so it accepts any value — the object-literal spelling of a bare `true`. Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, which is the point: as written it constrains nothing. Warning: tool "add" Path: inputSchema.properties.a Issue: `$ref` points outside this document (`https://example.com/schemas/number.json`). Clients do not fetch remote schemas, so the constraint is dropped or the tool is rejected. Suggestion: Inline the referenced schema, or move it into `$defs` and reference it as `#/$defs/`. 1 error, 3 warnings across 3 tools. ``` Note the shape of that suggestion. Where a replacement genuinely narrows the schema it says so, rather than implying an equivalent rewrite — and where an equivalent exists it gives that instead: an array-form `type` becomes `anyOf` branches (not "drop it from `required`", since absent is not the same as `null`), and a bare `false` becomes `{"not": {}}` (not "delete the entry", which would *permit* the property under the default `additionalProperties`). Severity decides the exit code, not the report: **errors** are constructs a shipping MCP client refuses outright (a bare `true` or `false` where a schema object belongs), **warnings** are ones handled unevenly (an array-form `type`, a remote `$ref`, a schema carrying no constraining keyword at all). Only errors fail the run — a `--strict` that failed on warnings would be unusable as a CI gate against servers that are in fact fine. There is deliberately **no "inputSchema must be an object" check**, even though MCP requires one. The SDK types `inputSchema` with `type: literal("object")`, so a tool whose input root is anything else fails `ListToolsResultSchema` and is dropped from the list before the lint could see it — it is reported through the malformed-items path instead. A rule that cannot fire would only make this documentation claim a check the CLI does not perform. The report goes to stderr, so the result on stdout stays parseable. Under `--format json` the findings are folded into the same envelope instead (`{"result":…,"schemaFindings":[…]}`), so a caller reads one document rather than correlating two. **Without `--strict` nothing changes except one line.** A `tools/list` whose schemas have findings prints a single stderr summary — `Schema portability: 1 error, 3 warnings across 3 tools. Re-run with --strict for details.` — and still exits `0`. A clean list prints nothing at all. This is deliberately not a JSON Schema validator. A census of 617 public servers found **zero** that fail the SDK's own parser, so a conformance check would report nothing on essentially every real server; what bites is the narrower subset each consumer accepts, which is what these rules encode. The same verdict drives the TUI's tool detail pane and the web Tools tab — all three read `core/json/schemaLint`. ### CLI-specific (OAuth for HTTP servers) The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). **CLI (`mcp-inspector --cli`):** on connect **401** or mid-session interactive auth (re-login / step-up), it: 1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`) 2. Prints the authorization URL to stderr (OSC 8 hyperlink when stderr is a TTY) and **opens the default browser** when allowed. Default: open on a **stderr** TTY (so `2>&1 | tee` prints a plain URL into the log but does not auto-open); plain URL only when stderr is non-TTY. `MCP_AUTO_OPEN_ENABLED=false` never opens; `=true` forces open even on a non-TTY (same as the web launcher). 3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC Interactive OAuth (connect-time or mid-RPC) requires a TTY on **stdin or stderr** (so `2>&1 | tee` still works — stdin stays a TTY), or `MCP_AUTO_OPEN_ENABLED=true`. When neither stdin nor stderr is a TTY and that env is unset (typical CI), the CLI fails fast with `auth_required` instead of waiting up to 15 minutes on the loopback callback. Use **`--stored-auth-only`** for non-interactive runs that should only consume the shared store. **Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues (including piped stdin — `echo y | …` or `printf y | …`); **N** or EOF with no answer (`< /dev/null` / Ctrl-D) declines. Piped answers must be **newline-terminated, or stdin must close** — a bare `y` held open without `\n` or EOF is not flushed as a line and times out. A non-TTY stdin that never sends a line within **5 seconds** fails with `auth_required` (`timed out`, not the same as an explicit **N**). Answering **y** only confirms step-up — the following browser/loopback OAuth can still wait up to 15 minutes; for headless CI prefer **`--stored-auth-only`** with tokens already in the store. EMA step-up re-mints silently (no prompt). **Shared OAuth storage:** the CLI **reuses** tokens stored by other Inspector clients — indexed by the shared `~/.mcp-inspector/storage/oauth.json`, with the tokens themselves held in the secret store. That is passive storage sharing, not launching another app. **Shared with TUI** (config only, not interactive login): - Per-server OAuth fields from `mcp.json` (static client, EMA resource credentials, scopes) - Install-level settings from **`~/.mcp-inspector/storage/client.json`** (or `--client-config` / `MCP_CLIENT_CONFIG_PATH`) — EMA IdP, CIMD - CLI flags `--client-id`, `--client-secret`, `--client-metadata-url` override `client.json` when set - Keychain-backed secrets in `mcp.json` are rehydrated on catalog load (same as TUI) #### OAuth callback URL | Surface | Default callback | | ------- | ---------------------------------------------------------------------------------- | | **Web** | `http://localhost:6274/oauth/callback` | | **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) | | **CLI** | `http://127.0.0.1:6276/oauth/callback` (interactive — same callback server as TUI) | Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** or **CLI**. Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Only one process should bind the default port at a time. #### Flags | Option | Env | Description | | ----------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--client-config ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | | `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | | `--client-secret ` | — | OAuth client secret; overrides `client.json`. | | `--client-metadata-url ` | — | CIMD metadata URL; overrides `client.json`. | | `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). Must bind a **loopback** host (`localhost` / `127.0.0.0/8` / `[::1]`) — the listener receives the authorization code over plaintext `http`, so a non-loopback host hard-errors (no opt-in; use a port-forward if the browser is elsewhere). | | — | `MCP_AUTO_OPEN_ENABLED` | Browser auto-open **and** non-TTY interactive-OAuth admit: `true` (allow interactive OAuth without a TTY **and** force-open the browser — same as the web launcher), `false` (never open), unset (open on a TTY unless `VITEST` is set). The authorization URL is always printed first; an open that fails, or does not launch within 5s, adds a stderr line saying to open it by hand. For CI that must not hang, prefer `--stored-auth-only`. | **Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line: ```bash npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-server \ --client-metadata-url https://example.com/.well-known/oauth/client-metadata.json \ --method tools/list ``` See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) (§3 Stytch/CIMD; [§5 mid-session manual validation](../../specification/v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CLI **C1–C2**). #### Stored-auth (web → CLI handoff) For the common case where OAuth was already completed in the **web inspector on the same machine**, the CLI can reuse the resulting token instead of running its own interactive flow. It reads the shared OAuth state (the `oauth.json` the web backend writes, joined with the tokens in the secret store) and injects `Authorization: Bearer ` for `--server-url`. | Option | Description | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--use-stored-auth` | Read the stored auth for `--server-url` and inject `Authorization: Bearer`. When a `refresh_token` is stored, the CLI runs the OAuth refresh grant first and injects the **fresh** access token (persisting the rotation); otherwise it injects the stored access token. Exits `3` (`no_stored_token`) — listing the stored keys — when nothing matches. Requires `--server-url`. | | `--wait-for-auth ` | Poll the OAuth state file (500 ms interval) until an access token for `--server-url` appears, then inject it. Times out at `` with exit `3` (`auth_wait_timeout`). Use after handing off to a human to complete OAuth in a browser. Unlike `--use-stored-auth`, this injects the freshly-landed access token directly (a token that just completed the browser flow is not expired), so it does **not** run the refresh grant. | | `--list-stored-auth` | Print `{ oauthStatePath, storedServerUrls }` (the server keys that currently have a token) and exit. No server connection is made. | | `--print-handoff` | Print a JSON handoff block (`deepLink`, `portForwardCmd`, `oauthStatePath`, `apiToken`) for `--server-url` and exit — everything a script/remote VM needs to drive the browser-side OAuth dance. Requires `--server-url`. | **State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves. **Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The write-back updates only the refreshed issuer slot's tokens, against the entry as it is on disk at write time, so a same-server change made while the grant was in flight (say, a web OAuth flow against another authorization server) is kept rather than overwritten, and a slot whose tokens were cleared or replaced meanwhile is left as it is (#2574). The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. Because there is no stored expiry, a `refresh_token` is refreshed on every `--use-stored-auth` run. Two consequences with rotating refresh tokens: two concurrent invocations against the same state file race the single-use token (one wins), and a crash between a successful grant and the write-back leaves the rotated token unsaved. Both are narrow; re-authorize in the web inspector to recover. **Short-circuit modes.** `--list-stored-auth` and `--print-handoff` each print their output and exit without connecting to a server; they ignore the method/target flags. They are mutually exclusive — if both are passed, `--list-stored-auth` takes precedence. The `deepLink` is the canonical web deep-link ([#1576](https://github.com/modelcontextprotocol/inspector/issues/1576)) — `http://:/?serverUrl=&transport=&autoConnect=` — so navigating it in a browser reaches a connected inspector in one shot. `transport` is derived from the resolved server (`--transport`, else auto-detected from the URL path: `/sse` → `sse`, else `http`), not hardcoded. `autoConnect` is set to `MCP_INSPECTOR_API_TOKEN`; when that env var is unset the link is still emitted but a `note` field flags that the web app's `autoConnect` gate will reject it until the inspector is launched with a known token. ```bash # On a remote VM: print what a human needs to complete OAuth in their browser. mcp-inspector --cli --server-url https://api.example/mcp --print-handoff # Then block until the token lands and run the call with it. mcp-inspector --cli --transport http --server-url https://api.example/mcp \ --wait-for-auth 120 --method tools/list ``` #### Skill verification (`--verify`) SEP-2640 puts real obligations on whoever consumes a skill: verify each fetched file against the digest its manifest advertised, check that the served `SKILL.md`'s frontmatter matches the one the listing advertised, and honour the per-skill limits. `--verify` runs all of them over a whole catalog and turns the answer into an exit code, so a server author can gate CI on it: ```sh mcp-inspector --cli --method skills/list --verify ``` Stdout is **NDJSON, one report per skill**, in listing order: ```json { "uri": "skill://tampered-notes/SKILL.md", "name": "tampered-notes", "conformance": [], "frontmatter": [], "files": [ { "uri": "skill://tampered-notes/SKILL.md", "status": "verified", "…": "…" }, { "uri": "skill://tampered-notes/notes.md", "status": "mismatch", "…": "…" } ], "ok": false } ``` Stderr gets a one-line summary, so a reader who piped stdout into `jq` still sees the verdict — and then, on a failing run, the ordinary [`ErrorEnvelope`](#exit-codes--error-envelopes) line that **every** non-zero exit writes. Two stderr lines on failure, one on success, which is the same shape `--strict` produces and is why the envelope is not suppressed here: a caller branching on `.code` should not have to special-case this command. `--method skills/get --uri ` verifies exactly one skill, in the same shape. **What fails the run.** Four outcomes, because "this skill is wrong", "this skill could not be fully checked" and "this skill offered nothing to check" are different answers: | `outcome` | Exit | When | | --- | --- | --- | | `verified` | `0` | Everything was checked and everything passed. | | `failed` | `7` | Something SEP-2640 makes a MUST was broken — an error-severity finding, a digest or size mismatch, or an unreadable manifest file. | | `incomplete` | `8` | Nothing checked was wrong, but the read bounds stopped the walk before it finished. See `incomplete` in the report for the reason. | | `unverifiable` | `0`, or `9` with `--require-digests` | Nothing checked was wrong, but `resources` is `"dynamic"`: no digest was advertised, so nothing was hashed. `SKILL.md` is still read for the frontmatter cross-check. | When a catalog mixes them, the exit code is the loudest: `7` over `8` over `9`. **The run is bounded, and says when a bound bit.** Three limits, all reported as `incomplete` (`8`) rather than as a pass or a failure, because an entry that was not read has not been cleared of anything: | Bound | Limit | Why | | --- | --- | --- | | Per skill | 512 manifest entries / 16 MiB | SEP-2640's own interoperability limits. | | Per skill, on the wire | 16 MiB actually served | The declared sizes are server-controlled; this one cannot be lied past. | | Per run | 256 skills / 64 MiB | SEP-2640 bounds a skill and deliberately does not bound a *catalog*. Every entry costs at least one `resources/read`, so without this a large listing — hostile or merely big — is unbounded work against the tool inspecting it. | The run bound is this tool's, not the spec's, and it is configurable per server: set `skillCatalogMaxSkills` / `skillCatalogMaxBytes` on the server's entry in `mcp.json`, or edit them under **Skills** in the web client's Server Settings (see [the configuration reference](../../docs/mcp-server-configuration.md)). Both must be positive integers — there is no unlimited value, since the bound is what makes the run terminate. A skill past it is still reported, with its static conformance findings and an `incomplete` reason saying nothing about its files was checked; verify it on its own with `--method skills/get --uri ` to get a verdict for it. A **warning** never produces `7`. That distinction matters most for `resources: "dynamic"`, which is a *conforming* wire form for generated content: it means integrity cannot be verified, which is worth reporting, but failing CI for it would tell server authors their valid skill is broken. It is not `verified` either — SEP-2640 calls such a `SKILL.md` "unverifiable", and reporting it with the same outcome as a skill whose every file hashed clean gave CI no way to tell them apart (#2405). So it gets its own outcome, and the stderr headline says how many skills advertised no digests. SEP-2640 also says "Hosts MAY decline to load such skills"; a CI job standing in for such a host passes `--require-digests` to turn that outcome into exit `9`. **Three checks, three different jobs**, and the second is the one nothing else covers: - **Conformance** — structural checks against the entry as listed (name grammar, the name/URI invariant, digest and size formats, manifest completeness, the interoperability limits). - **Frontmatter** — the served `SKILL.md`'s own YAML frontmatter, compared field by field against the frontmatter the listing advertised. A digest cannot cover this: it is taken over the bytes the server served, so it proves the file was not altered in transit and says nothing about whether the *listing* described it honestly. A server can advertise one description, serve another, and pass every digest check. - **Files** — each manifest entry fetched and hashed. Reads are sequential: a conforming manifest may declare 512 entries, and a parallel walk would open 512 `resources/read` calls against the server under test. A read failure is recorded against the file it happened on and the walk continues, so one unreadable file never hides the findings after it. ## Exit codes & error envelopes Every non-zero exit maps to a stable failure class, so a programmatic caller (CI, a script, an agent) can branch on _why_ the CLI failed without scraping prose from stderr: | Code | Meaning | | ---- | ----------------------------------------------------------------------------- | | `0` | Success. | | `1` | Usage / unexpected error (the catch-all). | | `2` | No MCP App found on the tool (`--app-info` probe). | | `3` | Server requires authentication (401/403 or a typed SDK auth error). | | `4` | Server unreachable (DNS, connection refused, timeout, `fetch failed`). | | `5` | Tool error (`tools/call` returned `isError:true`, or the tool was not found). | | `6` | `--strict` found an error-severity tool-schema portability problem (`schema_unportable` — the schema is valid JSON Schema, just not portable). | | `7` | `--verify` found a SEP-2640 violation (`skills_nonconformant` — a conformance error, a digest or size mismatch, or an unreadable manifest file). | | `8` | `--verify` could not check the whole catalog (`skills_incomplete` — the read bounds stopped the walk). The server broke no **MUST**: the 512-entry and 16 MiB limits are `SHOULD NOT`, and hosts may support more. A job that tolerates oversized catalogs can allow `8` and still fail on `7`. | | `9` | `--verify --require-digests` found a skill that advertised no digests (`skills_unverifiable` — `resources: "dynamic"`). Only produced under `--require-digests`; without it such a skill is reported `unverifiable` and the run exits `0`. | On any non-zero exit the CLI also writes a single JSON line to **stderr** — the `ErrorEnvelope`: ```json { "error": { "code": "auth_required", "message": "Unauthorized", "status": 401, "url": "https://api.example/mcp" } } ``` The `code` is a stable identifier for the failure class; `message` is the human-readable error; `cause`, `status`, and `url` are included when known. Because it is one line, a caller can parse it with `2>&1 | tail -1 | jq .error`. ## Why use the CLI? For a copyable connect → list → call → assert workflow built on the flags above, see [Smoke-testing an MCP server](../../docs/cli-smoke-testing.md). While the Web Client provides a rich visual interface, the CLI is designed for: - **Automation**: Ideal for CI/CD pipelines and batch processing. - **AI Coding Assistants**: Provides a direct, machine-readable interface (JSON) for tools like Cursor or Claude to verify changes immediately. - **Log Analysis**: Easier integration with command-line utilities (like `jq`) to process and analyze MCP server output. ## Development Like the other clients, the CLI self-validates from its own folder: ```bash npm run check # format:check && lint && typecheck (no tests) npm run validate # check && test (fast; no coverage gate) npm test # build test-servers + binary, then run all tests npm run test:coverage # build + tests under the per-file ≥90 coverage gate ``` The CLI's `test` / `test:coverage` **build the binary first** (out-of-process `e2e.test.ts` spawns it). `check` is `format:check && lint && typecheck` and `validate` is `check && test`, with no separate `build` step (`pretest` builds). Repo-root `validate:cli` delegates to `validate`, and the root `local:validate` — the first stage of `npm run local:gate` — runs `check` instead, so the gate runs the suite once, under `coverage` / `coverage:cli` (#2341), matching AGENTS.md. Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured under coverage, with a thin out-of-process spawn layer for the real binary. See [`__tests__/README.md`](./__tests__/README.md) for details.