Skip to content
Closed
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
23 changes: 15 additions & 8 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ All three run through one global `mcp-inspector` binary:

```bash
npx @modelcontextprotocol/inspector # web UI (default)
npx @modelcontextprotocol/inspector --cli # CLI
npx @modelcontextprotocol/inspector --cli # one-shot CLI
npx @modelcontextprotocol/inspector --tui # TUI
```

Expand All @@ -24,7 +24,7 @@ v2 is **not** an npm workspace. Each client under `clients/*` keeps its own `pac
inspector/
├── clients/
│ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend
│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias)
│ ├── cli/ # CLI client (tsup bundle) — one-shot `mcp-inspector --cli`
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
Expand Down
35 changes: 19 additions & 16 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# MCP Inspector CLI Client

The CLI mode enables programmatic interaction with MCP servers from the command line. It is ideal for scripting, automation, continuous integration, and establishing an efficient feedback loop with AI coding assistants.
One-shot CLI: connect → one `--method` → disconnect. Invoked as `mcp-inspector --cli`.

## Running the CLI
## One-shot CLI (`mcp-inspector --cli`)

You can run the CLI client directly via `npx`:
You can run the one-shot CLI via `npx`:

```bash
npx @modelcontextprotocol/inspector --cli node build/index.js
```

The CLI mode supports operations across tools, resources, and prompts, returning structured JSON output.
Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting).

### Examples

Expand Down Expand Up @@ -98,7 +98,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en

| Option | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------- |
| `--method <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`. |
| `--method <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`, plus catalog-only `servers/list` / `servers/show` (no MCP connect). Stream / session-only methods (e.g. `logging/tail`) are rejected. |
| `--tool-name <name>` | Tool name (for `tools/call`). |
| `--tool-arg <key=value>` | 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 <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`. |
Expand All @@ -111,6 +111,8 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--connect-timeout <ms>` | 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 timeout for `--catalog`/`--config` runs. `0` disables the timeout. |
| `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name <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. |
| `--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`. |
| `--relogin` | Ignore any stored OAuth for this run’s server URL before connect; interactive login still only runs if the server requires auth. No-op for stdio (no URL-keyed store entry). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth`. |
| `--stored-auth-only` | Never start interactive OAuth / step-up; use the shared store if present, otherwise fail with `auth_required`. |

#### App probing (`--app-info`) and machine-readable output (`--format json`)

Expand Down Expand Up @@ -140,13 +142,15 @@ A `tools/call` that returns `isError:true` still prints its payload but exits `5

### 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). On connect **401** or mid-session interactive auth (re-login / step-up), it:
The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default).

**One-shot (`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 the console (`ConsoleNavigation`)
2. Prints the authorization URL to stderr (OSC 8 hyperlink when stderr is a TTY) and **opens the default browser** on a TTY; non-TTY / CI prints a plain URL only and never launches a browser
3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC

**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt).
**Step-up (standard OAuth, one-shot only):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt).

**Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app.

Expand Down Expand Up @@ -256,17 +260,16 @@ While the Web Client provides a rich visual interface, the CLI is designed for:
Like the other clients, the CLI self-validates from its own folder:

```bash
npm run validate # format:check && lint && test:coverage
npm run validate # format:check && lint && 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 coverage gate
npm run test:coverage # build + tests under the per-file ≥90 coverage gate
```

The CLI's `test:coverage` **builds the binary first** (its out-of-process
`e2e.test.ts` spawns it, so it must run against a fresh build). `validate`
therefore folds the build into `test:coverage` rather than repeating it — it is
`format:check && lint && test:coverage`, with no separate `build` step (the
other clients, whose tests don't spawn their bundle, keep an explicit `build`).
The repo-root `validate:cli` just delegates here.
The CLI's `test` / `test:coverage` **build the binary first** (out-of-process
`e2e.test.ts` spawns it). `validate` is `format:check && lint && test` with no
separate `build` step (`pretest` builds). Repo-root `validate:cli` delegates
here; the coverage gate is `npm run coverage` / `coverage:cli` (also in
`npm run ci`), 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
Expand Down
9 changes: 5 additions & 4 deletions clients/cli/__tests__/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# CLI Tests
# CLI Tests (one-shot)

## Running Tests

Expand Down Expand Up @@ -47,6 +47,8 @@ root provides a further end-to-end check of the binary.)
- `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry)
- `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e)
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth)
- `style.test.ts` - ANSI / OSC 8 helpers used by OAuth navigation prompts
- `stored-auth.test.ts` / `programmatic-ergonomics.test.ts` - Stored-auth / handoff / flag conflicts

## Helpers

Expand All @@ -67,7 +69,6 @@ and the bundled stdio server is launched via `getTestMcpServerCommand()`.
a file run sequentially
- Config files use `crypto.randomUUID()` for uniqueness
- HTTP/SSE servers use dynamic port allocation to avoid conflicts
- Coverage is enforced per-file (lines ≥ 90, statements ≥ 85, functions ≥ 80,
branches ≥ 50). `src/index.ts` (the binary bootstrap) is excluded because it
only runs in the spawned binary, which coverage can't instrument
- Coverage is enforced per-file at ≥90 on lines, statements, functions, and
branches. Exclusion: `src/index.ts` (see `vitest.config.ts`)
- All tests use the built-in MCP test servers — no external/registry dependencies
68 changes: 68 additions & 0 deletions clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
getStateFilePath,
resetNodeOAuthStorageCache,
} from "@inspector/core/auth/node/storage-node.js";
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";

describe("clearStoredAuthForRelogin", () => {
let dir: string | undefined;
let prevPath: string | undefined;

afterEach(() => {
if (prevPath === undefined)
delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath;
resetNodeOAuthStorageCache();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});

it("no-ops when serverUrl is missing or blank", async () => {
await clearStoredAuthForRelogin(undefined);
await clearStoredAuthForRelogin(" ");
});

it("clears a URL-keyed entry and tolerates non-URL keys", async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-"));
const file = path.join(dir, "oauth.json");
fs.writeFileSync(
file,
JSON.stringify({
servers: {
"https://example.com/mcp": {
tokens: { access_token: "a", token_type: "Bearer" },
},
"not a url": {
tokens: { access_token: "b", token_type: "Bearer" },
},
},
idpSessions: {},
}),
"utf8",
);
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
resetNodeOAuthStorageCache();
expect(getStateFilePath()).toBe(file);

await clearStoredAuthForRelogin("https://example.com/mcp");
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
expect(blob.servers["not a url"]).toBeDefined();

// normalizeServerUrl catch path — clears under the raw key
await clearStoredAuthForRelogin("not a url");
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["not a url"]).toBeUndefined();
});
});
114 changes: 114 additions & 0 deletions clients/cli/__tests__/cli-oauth-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCliOAuthNavigation } from "../src/cli-oauth-navigation.js";
import { openUrl } from "../src/open-url.js";

vi.mock("../src/open-url.js", () => ({
openUrl: vi.fn().mockResolvedValue(undefined),
}));

const openUrlMock = vi.mocked(openUrl);

describe("createCliOAuthNavigation", () => {
afterEach(() => {
openUrlMock.mockClear();
openUrlMock.mockResolvedValue(undefined);
});

it("prints OSC 8 link and opens browser on a TTY", async () => {
const lines: string[] = [];
const openBrowser = vi.fn().mockResolvedValue(undefined);
const nav = createCliOAuthNavigation({
isTTY: true,
// Force ANSI on even when the test runner exports NO_COLOR.
noColorEnv: "",
write: (line) => lines.push(line),
openBrowser,
});
const url = new URL("https://as.example/authorize?x=1");
nav.navigateToAuthorization(url);
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
expect(lines.join("")).toContain("Please navigate to:");
expect(lines.join("")).toContain(
"\u001b]8;;https://as.example/authorize?x=1\u0007",
);
expect(openBrowser).toHaveBeenCalledWith(
"https://as.example/authorize?x=1",
);
expect(openUrlMock).not.toHaveBeenCalled();
});

it("prints a plain URL and does not open a browser when not a TTY", async () => {
const lines: string[] = [];
const openBrowser = vi.fn();
const nav = createCliOAuthNavigation({
isTTY: false,
write: (line) => lines.push(line),
openBrowser,
});
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
await vi.waitFor(() => expect(lines.length).toBe(1));
expect(lines.join("")).toBe(
"Please navigate to: https://as.example/authorize\n",
);
expect(lines.join("")).not.toContain("\u001b]8;;");
expect(openBrowser).not.toHaveBeenCalled();
expect(openUrlMock).not.toHaveBeenCalled();
});

it("skips OSC 8 when NO_COLOR is set but still opens on a TTY", async () => {
const lines: string[] = [];
const openBrowser = vi.fn().mockResolvedValue(undefined);
const nav = createCliOAuthNavigation({
isTTY: true,
noColorEnv: "1",
write: (line) => lines.push(line),
openBrowser,
});
nav.navigateToAuthorization(new URL("https://as.example/a"));
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
expect(lines.join("")).toBe("Please navigate to: https://as.example/a\n");
expect(lines.join("")).not.toContain("\u001b]8;;");
});

it("swallows browser-open failures after printing the URL", async () => {
const lines: string[] = [];
const openBrowser = vi.fn().mockRejectedValue(new Error("no browser"));
const nav = createCliOAuthNavigation({
isTTY: true,
noColorEnv: "1",
write: (line) => lines.push(line),
openBrowser,
});
nav.navigateToAuthorization(new URL("https://as.example/a"));
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
expect(lines.join("")).toContain("Please navigate to:");
});

it("writes to stderr and uses openUrl by default on a TTY", async () => {
const writeSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const ttyDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
Object.defineProperty(process.stderr, "isTTY", {
configurable: true,
get: () => true,
});
try {
const nav = createCliOAuthNavigation({
noColorEnv: "1",
});
nav.navigateToAuthorization(new URL("https://as.example/default"));
await vi.waitFor(() => expect(openUrlMock).toHaveBeenCalledOnce());
expect(openUrlMock).toHaveBeenCalledWith("https://as.example/default");
const written = writeSpy.mock.calls.map((c) => String(c[0])).join("");
expect(written).toContain(
"Please navigate to: https://as.example/default",
);
} finally {
writeSpy.mockRestore();
if (ttyDesc) {
Object.defineProperty(process.stderr, "isTTY", ttyDesc);
}
}
});
});
71 changes: 71 additions & 0 deletions clients/cli/__tests__/cliOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,5 +453,76 @@ describe("cliOAuth", () => {
),
).rejects.toThrow("nope (401)");
});

it("fails with AUTH_REQUIRED under --stored-auth-only without interactive OAuth", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const connect = vi.fn().mockRejectedValue(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "token_expired",
}),
);
const client = {
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
};

await expect(
connectInspectorWithOAuth(
client,
oauthServerConfig,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({ exitCode: 3 });
expect(runSpy).not.toHaveBeenCalled();
});

it("fails stored-auth-only on plain unauthorized errors", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const connect = vi
.fn()
.mockRejectedValue(new Error("Connection failed for server (401)"));
const client = {
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};

await expect(
connectInspectorWithOAuth(
client,
oauthServerConfig,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({ exitCode: 3 });
expect(runSpy).not.toHaveBeenCalled();
});
});

it("withCliAuthRecoveryRetry respects storedAuthOnly", async () => {
const fn = vi.fn().mockRejectedValue(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "token_expired",
}),
);
await expect(
withCliAuthRecoveryRetry(
{
checkAuthChallengeSatisfied: vi.fn(),
} as never,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
undefined,
fn,
undefined,
{ storedAuthOnly: true },
),
).rejects.toMatchObject({ exitCode: 3 });
});
});
Loading
Loading