Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--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). |
| `--format <text\|json>` | 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). |
| `--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. |
Expand All @@ -149,6 +150,38 @@ mcp-inspector --cli --server-url https://example.com/mcp --relogin --no-revoke -

`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.

#### 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:
Expand Down
105 changes: 104 additions & 1 deletion clients/cli/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, it, expect, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli } from "./helpers/cli-runner.js";
Expand Down Expand Up @@ -85,6 +85,109 @@ describe("CLI Tests", () => {
expect(toolNames).toContain("get_annotated_message");
});

// #2435. In-process, a stdio server's own stderr goes straight to the
// worker's fd 2 rather than through the captured `process.stderr.write`,
// so the child-stderr half is asserted out of process in e2e.test.ts.
it.each(["-q", "--quiet"])(
"%s prints only the result payload",
async (flag) => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
flag,
"--method",
"tools/list",
]);

expectCliSuccess(result);
expect(expectValidJson(result)).toHaveProperty("tools");
expect(result.stderr).toBe("");
},
);

// A usage error makes Commander print its own `error: …` line during
// `parse()`, before the envelope. Under `--quiet` that line is dropped and
// stderr is the envelope alone (Copilot on #2576).
it("drops Commander's usage diagnostic under --quiet, keeping only the envelope", async () => {
const loud = await runCli([NO_SERVER_SENTINEL, "--method"]);
expectCliFailure(loud);
expect(loud.stderr).toMatch(/^error: option '--method <method>'/);

const quiet = await runCli([NO_SERVER_SENTINEL, "-q", "--method"]);
expectCliFailure(quiet);
const lines = quiet.stderr.trimEnd().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0]!)).toMatchObject({
error: { message: expect.stringMatching(/--method <method>/) },
});
});

// Core reports advisories with `console.warn` from many places — here
// `cleanRoots()` on a hand-edited entry. Vitest replaces `console`, so the
// captured stderr cannot show it; the spy is what observes the channel.
// Under `--quiet` the run swaps `console.warn` out and restores it after
// (Copilot on #2576).
it("mutes core's console.warn advisories for a --quiet run, and restores console.warn after", async () => {
const { command, args } = getTestMcpServerCommand();
const dir = mkdtempSync(join(tmpdir(), "cli-quiet-roots-"));
const configPath = join(dir, "mcp.json");
// A root with no `uri` is dropped with a warning (core/mcp/serverList.ts).
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
s: { type: "stdio", command, args, roots: [{ name: "no-uri" }] },
},
}),
);
const run = (extra: string[]) =>
runCli(["--config", configPath, ...extra, "--method", "tools/list"]);
try {
const loud = vi.spyOn(console, "warn").mockImplementation(() => {});
expectCliSuccess(await run([]));
expect(loud).toHaveBeenCalledWith(
"Dropping root without a string `uri`:",
expect.anything(),
);
loud.mockRestore();

const quiet = vi.spyOn(console, "warn").mockImplementation(() => {});
expectCliSuccess(await run(["-q"]));
expect(quiet).not.toHaveBeenCalled();
expect(console.warn).toBe(quiet);
quiet.mockRestore();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

// Quiet is read from Commander's parsed state, so a combined `-qe` counts
// and a `-q` that is another option's value does not (Copilot on #2576).
it("applies the pre-parse --quiet handling when -q is combined with -e", async () => {
const result = await runCli([
NO_SERVER_SENTINEL,
"-qe",
"A=1",
"--method",
]);
expectCliFailure(result);
const lines = result.stderr.trimEnd().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0]!)).toHaveProperty("error");
});

it("does not treat -q as the flag when it is another option's value", async () => {
const result = await runCli([
NO_SERVER_SENTINEL,
"--client-secret",
"-q",
"--method",
]);
expectCliFailure(result);
expect(result.stderr).toMatch(/^error: option '--method <method>'/);
});

it("should fail with nonexistent method", async () => {
const result = await runCli([
NO_SERVER_SENTINEL,
Expand Down
123 changes: 123 additions & 0 deletions clients/cli/__tests__/cliOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,4 +1184,127 @@ describe("cliOAuth", () => {
}
}
});

// #2435: `--quiet` drops the status lines, never the prompts a human has to
// act on. The step-up [y/N] still prints, because the flow cannot finish
// without an answer to it.
describe("--quiet", () => {
const QUIET_CALLBACK = CALLBACK_URL_CONFIG;
function fakeClient(connect = vi.fn().mockResolvedValue(undefined)) {
return {
connect,
disconnect: vi.fn().mockResolvedValue(undefined),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
};
}

it("runCliInteractiveOAuth writes no success line", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);

await runCliInteractiveOAuth(
fakeClient(),
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
{ quiet: true },
);

expect(stderrSpy).not.toHaveBeenCalled();
});

it("withCliAuthRecoveryRetry writes no retry line after AuthRecoveryRequired, but keeps the step-up prompt", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const fn = vi
.fn()
.mockRejectedValueOnce(
new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
),
)
.mockResolvedValueOnce("ok");

const result = await withCliAuthRecoveryRetry(
fakeClient(),
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
makeFakeServerSettings(),
fn,
{ ...INTERACTIVE, confirmStepUp: async () => true, quiet: true },
);

expect(result).toBe("ok");
const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
expect(written).toContain("Proceed with step-up authorization? [y/N]");
expect(written).not.toContain("Authorization complete");
});

it("withCliAuthRecoveryRetry writes no retry line after a plain 401", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("RPC failed (401)"))
.mockResolvedValueOnce("ok");

const result = await withCliAuthRecoveryRetry(
fakeClient(),
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
undefined,
fn,
{ ...INTERACTIVE, quiet: true },
);

expect(result).toBe("ok");
expect(stderrSpy).not.toHaveBeenCalled();
});

it("connectInspectorWithOAuth writes no success line after a 401 on connect", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const connect = vi
.fn()
.mockRejectedValueOnce(new Error("RPC failed (401)"))
.mockResolvedValue(undefined);

await connectInspectorWithOAuth(
fakeClient(connect),
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
undefined,
{ ...INTERACTIVE, quiet: true },
);

expect(connect).toHaveBeenCalledTimes(2);
expect(stderrSpy).not.toHaveBeenCalled();
});
});
});
Loading
Loading