Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ test-servers/build
/*.png
/inspector-history-*.json
/*.server.json
/configs/
43 changes: 43 additions & 0 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,49 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |

### CLI-specific (OAuth for HTTP servers)

The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again.

The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server.

**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` (redirect URI in OAuth metadata only; no listener) |

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 when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port.

#### Flags

| Option | Env | Description |
| ------ | --- | ----------- |
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |

**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) for configuration details and staging servers.

## Why use the CLI?

While the Web Client provides a rich visual interface, the CLI is designed for:
Expand Down
19 changes: 19 additions & 0 deletions clients/cli/__tests__/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,22 @@ export function createInvalidConfig(): string {
export function deleteConfigFile(configPath: string): void {
cleanupTempDir(path.dirname(configPath));
}

/**
* Create a temporary install-level client.json for --client-config tests.
*/
export function createClientConfigFile(
config: Record<string, unknown>,
): string {
const tempDir = createTempDir("mcp-inspector-client-config-");
const configPath = path.join(tempDir, "client.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return configPath;
}

/**
* Delete a client config file and its containing directory
*/
export function deleteClientConfigFile(configPath: string): void {
cleanupTempDir(path.dirname(configPath));
}
188 changes: 188 additions & 0 deletions clients/cli/__tests__/oauth-runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import * as runner from "@inspector/core/client/runner.js";
import {
createTestServerHttp,
createEchoTool,
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
import { runCli } from "./helpers/cli-runner.js";
import {
expectCliFailure,
expectCliSuccess,
expectOutputContains,
} from "./helpers/assertions.js";
import {
createClientConfigFile,
deleteClientConfigFile,
} from "./helpers/fixtures.js";

/**
* Covers OAuth runner flag wiring in `src/cli.ts` (#1514): --client-config,
* --client-id/--client-secret/--client-metadata-url, and --callback-url /
* MCP_OAUTH_CALLBACK_URL. Shared auth logic is unit-tested in
* clients/web/src/test/core/client/runner.test.ts; these assert the CLI
* parses flags and passes them through on HTTP (OAuth-capable) connects.
*/
describe("CLI OAuth runner flags", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("connects over HTTP with OAuth CLI overrides and custom callback URL", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions");

try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--client-id",
"test-client-id",
"--client-secret",
"test-client-secret",
"--client-metadata-url",
"https://example.com/oauth/client-metadata.json",
"--callback-url",
"http://127.0.0.1:9999/oauth/callback",
]);

expectCliSuccess(result);
expect(buildSpy).toHaveBeenCalled();
expect(buildSpy.mock.calls[0]?.[2]).toEqual({
clientId: "test-client-id",
clientSecret: "test-client-secret",
clientMetadataUrl: "https://example.com/oauth/client-metadata.json",
});
} finally {
await server.stop();
}
});

it("loads install client.json from --client-config and prefers CLI CIMD override", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions");
const clientConfigPath = createClientConfigFile({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/from-client-json.json",
},
});

try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--client-config",
clientConfigPath,
"--client-metadata-url",
"https://example.com/from-cli-flag.json",
]);

expectCliSuccess(result);
expect(buildSpy).toHaveBeenCalled();
expect(buildSpy.mock.calls[0]?.[0]).toMatchObject({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/from-client-json.json",
},
});
expect(buildSpy.mock.calls[0]?.[2]).toEqual({
clientMetadataUrl: "https://example.com/from-cli-flag.json",
});
} finally {
await server.stop();
deleteClientConfigFile(clientConfigPath);
}
});

it("accepts port 0 callback URL for ephemeral listener binding", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});

try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--callback-url",
"http://127.0.0.1:0/oauth/callback",
]);

expectCliSuccess(result);
} finally {
await server.stop();
}
});

it("uses MCP_OAUTH_CALLBACK_URL when --callback-url is absent", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});

try {
await server.start();
const result = await runCli(
[server.url, "--cli", "--method", "tools/list", "--transport", "http"],
{
env: {
MCP_OAUTH_CALLBACK_URL:
"http://127.0.0.1:8888/custom/oauth/callback",
},
},
);

expectCliSuccess(result);
} finally {
await server.stop();
}
});

it("rejects an invalid OAuth callback URL before connecting", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});

try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--callback-url",
"not-a-valid-url",
]);

expectCliFailure(result);
expectOutputContains(result, "Invalid OAuth callback URL");
} finally {
await server.stop();
}
});
});
Loading
Loading