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
2 changes: 1 addition & 1 deletion .claude/skills/project-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ an MCP server, the request/response lifecycle, and a set of state stores.
| `core/auth/` | OAuth end to end — providers, discovery, storage, endpoint overrides, scopes, revocation, mid-session recovery — split into isomorphic logic plus `browser/`, `node/` and `remote/` backends |
| `core/auth/node/` | Node OAuth storage + loopback callback server, **and** the `SecretStore` backends (keychain / file / memory) and their selection policy |
| `core/extension/<name>/` | Host-side adapters for MCP extensions whose protocol an upstream SDK owns. `tasks/` wraps `@modelcontextprotocol/ext-tasks` with what that package leaves to its host: the raw `rawDispatch` channel, progress routing, error identity, and task-view conversions. `skills/` keeps what `@modelcontextprotocol/client/ext/skills` leaves to an inspector: permissive entry schemas (the SDK's refuse a non-conforming entry), the per-check conformance and verification report, and `SKILL.md` frontmatter parsing; constants, limits and the directory-read schema come from the SDK. Imports nothing from `InspectorClient`; host state arrives through narrow interfaces |
| `core/cli/` | Node-only surface the one-shot CLI and `mcpdo` both run on (#2461): `error-handler` (exit codes, `CliExitCodeError`, the error envelope), the method handlers and their option/format types (`handlers/`), the interactive OAuth connect flow (`cliOAuth`, `cli-oauth-navigation`) and output helpers (`style`, `utils/awaitable-log`). One-shot-only output (`emit-result`, `consume-outcome`, `servers-write`, …) stays in `clients/cli/src` |
| `core/cli/` | Node-only surface the one-shot CLI and `mcpdo` both run on (#2461): `error-handler` (exit codes, `CliExitCodeError`, the error envelope), the method handlers and their option/format types (`handlers/`), the interactive OAuth connect flow (`cliOAuth`, `cli-oauth-navigation`, and the `--oauth-debug` wiring in `oauth-debug`) and output helpers (`style`, `utils/awaitable-log`). One-shot-only output (`emit-result`, `consume-outcome`, `servers-write`, …) stays in `clients/cli/src` |
| `core/client/` | Install-level client config (`client.json`): browser-safe parse plus Node load/save, remote backend, secrets, runner |
| `core/json/` | JSON + parameter/argument conversion; the schema normalizations all three form builders share (nullable unions, root composition) and the tool-schema portability lint |
| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees. Every subscription reads its snapshot **during render** via `useSyncExternalStore` (#1955); `useStoreSnapshot.ts` caches the fresh-value-per-read getters |
Expand Down
26 changes: 26 additions & 0 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--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. |
| `--oauth-debug` | Print each OAuth step to stderr as it runs: the protected-resource and authorization-server metadata URLs and statuses, the client registration request and response, the authorization URL, and the token request and response. Tokens, codes, PKCE verifiers and client secrets are redacted. See [Tracing OAuth](#tracing-oauth---oauth-debug). |
| `--completion <shell>` | Print a `bash`, `zsh` or `fish` completion script and exit (no server connection). See [Shell completion](#shell-completion). |

#### Revoking on `--relogin`
Expand All @@ -185,6 +186,30 @@ mcp-inspector --cli --server-url https://example.com/mcp --relogin --method tool
mcp-inspector --cli --server-url https://example.com/mcp --relogin --no-revoke --method tools/list
```

#### Tracing OAuth (`--oauth-debug`)

When a login fails, the final error rarely says which step went wrong. `--oauth-debug` ([#2844](https://github.com/modelcontextprotocol/inspector/issues/2844)) prints every OAuth request the run makes to stderr, one `[oauth]` line for the request and one for the response, so the step that broke is the last one in the trace. It is the terminal counterpart of the web client's OAuth view, and it shows what was actually sent and received:

```text
[oauth] protected-resource metadata: GET https://example.com/.well-known/oauth-protected-resource/mcp
[oauth] -> 200 OK {"resource":"https://example.com/mcp","authorization_servers":["https://auth.example.com"]}
[oauth] authorization-server metadata: GET https://auth.example.com/.well-known/oauth-authorization-server
[oauth] -> 200 OK {"issuer":"https://auth.example.com","authorization_endpoint":"…","token_endpoint":"…","registration_endpoint":"…"}
[oauth] client registration: POST https://auth.example.com/register {"redirect_uris":["http://127.0.0.1:6276/oauth/callback"],…}
[oauth] -> 201 Created {"client_id":"abc123","client_secret":"[redacted]",…}
[oauth] authorization: open https://auth.example.com/authorize?response_type=code&client_id=abc123&…
[oauth] token request: POST https://auth.example.com/token grant_type=authorization_code code=[redacted] code_verifier=[redacted] …
[oauth] -> 200 OK {"access_token":"[redacted]","token_type":"Bearer","expires_in":3600,"scope":"mcp","refresh_token":"[redacted]"}
```

- **What is redacted.** Every value under a credential-bearing field name — `access_token`, `refresh_token`, `id_token`, `code`, `code_verifier`, `client_secret`, `registration_access_token`, the assertion and token-exchange fields, and `token` — in query strings, form bodies and JSON alike. An `Authorization` header shows only its scheme. Everything else prints, since client ids, redirect URIs, scopes, endpoints and `error_description` are what the trace is for. Metadata documents are cut down to the fields that decide where the flow goes next.
- **What is covered.** The discovery, registration, code exchange and refresh the connect runs, including any refresh `--use-stored-auth` makes. A failed request prints `-> failed: <reason>` — a destination the OAuth fetch guard refuses shows up there. A run whose stored token is still valid makes no OAuth requests and prints nothing; add `--relogin` to trace a fresh login.
- **Where it goes.** Stderr only, so `--format json` keeps stdout clean. It is printed under `--quiet` too, since you asked for it.

```bash
mcp-inspector --cli --server-url https://example.com/mcp --relogin --oauth-debug --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
Expand Down Expand Up @@ -230,6 +255,7 @@ What it suppresses:
| 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-debug` trace | Kept — you asked for it |
| 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
Expand Down
1 change: 1 addition & 0 deletions clients/cli/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ npm run validate # format:check && lint && typecheck && test
| `cli-oauth-navigation.test.ts` | OSC 8, arm/disarm, `MCP_AUTO_OPEN_ENABLED` |
| `oauth-runner.test.ts` | Runner client-config / CIMD flags |
| `oauth-interactive.test.ts` | Loopback callback OAuth (in-process) |
| `oauth-debug.test.ts` | `--oauth-debug` trace wiring + a traced interactive login (#2844) |
| `stored-auth.test.ts` | `--use-stored-auth` / handoff / wait |
| `clear-stored-auth-for-relogin.test.ts` | Dual-key `--relogin` clear |
| `programmatic-ergonomics.test.ts` | Flag conflicts, timeouts, ergonomics |
Expand Down
246 changes: 246 additions & 0 deletions clients/cli/__tests__/oauth-debug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* `--oauth-debug` (#2844): the terminal wiring in `core/cli/oauth-debug.ts`
* and the navigation hook it feeds. The trace format itself is unit-tested
* with `core/auth/oauthDebug.ts` (clients/web/src/test/core/auth/).
*/
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import {
TestServerHttp,
waitForOAuthWellKnown,
getDefaultServerConfig,
createOAuthTestServerConfig,
clearOAuthTestData,
} from "@modelcontextprotocol/inspector-test-server";
import { createServer, type AddressInfo } from "node:net";
import { runCli } from "./helpers/cli-runner.js";
import { createCliOAuthNavigation } from "@inspector/core/cli/cli-oauth-navigation.js";
import {
createCliOAuthFetch,
oauthDebugNavigationHook,
traceCliOAuthFetch,
writeOAuthDebugToStderr,
} from "@inspector/core/cli/oauth-debug.js";
import { OAUTH_DEBUG_PREFIX } from "@inspector/core/auth/oauthDebug.js";
import { openUrl } from "@inspector/core/node/openUrl.js";

vi.mock("@inspector/core/node/openUrl.js", () => ({
openUrl: vi.fn().mockResolvedValue(undefined),
}));

const openUrlMock = vi.mocked(openUrl);

/**
* Stand in for the browser: approve the test authorization server's consent
* page and deliver the redirect to the CLI's loopback callback.
*/
async function completeInBrowser(href: string | URL): Promise<void> {
const authorizationUrl = new URL(href);
let response = await fetch(authorizationUrl, { redirect: "manual" });
if (response.status === 200) {
response = await fetch(
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(authorizationUrl.searchParams),
redirect: "manual",
},
);
}
const location = response.headers.get("location");
if (!location) throw new Error(`No redirect (${String(response.status)})`);
await fetch(new URL(location, authorizationUrl.origin));
}

/** A free loopback port for this file's callback listener. */
async function freePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}

afterEach(() => {
vi.restoreAllMocks();
openUrlMock.mockReset();
openUrlMock.mockResolvedValue(undefined);
});

describe("createCliOAuthFetch", () => {
it("traces through the destination guard when on", async () => {
const lines: string[] = [];
const oauthFetch = createCliOAuthFetch(true, (line) => lines.push(line));
// The guard refuses a cloud-metadata address before any socket opens, so
// this exercises the real guard with no network.
await expect(
oauthFetch("http://169.254.169.254/.well-known/oauth-protected-resource"),
).rejects.toThrow();
expect(lines[0]).toBe(
`${OAUTH_DEBUG_PREFIX} protected-resource metadata: GET http://169.254.169.254/.well-known/oauth-protected-resource\n`,
);
expect(lines[1]).toMatch(/^\[oauth\] {3}-> failed: /);
});

it("is the bare guard, writing nothing, when off", async () => {
const write = vi.fn();
const oauthFetch = createCliOAuthFetch(false, write);
await expect(oauthFetch("http://169.254.169.254/x")).rejects.toThrow();
expect(write).not.toHaveBeenCalled();
});

it("writes to stderr by default", async () => {
const stderr = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
await expect(
createCliOAuthFetch(true)("http://169.254.169.254/x"),
).rejects.toThrow();
expect(stderr).toHaveBeenCalledWith(
`${OAUTH_DEBUG_PREFIX} request: GET http://169.254.169.254/x\n`,
);
});
});

describe("traceCliOAuthFetch", () => {
it("returns the fetch untouched when off", () => {
const base = vi.fn<typeof fetch>();
expect(traceCliOAuthFetch(base, false)).toBe(base);
});

it("traces a caller-composed fetch to stderr when on", async () => {
const stderr = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const base = vi.fn<typeof fetch>(
async () => new Response(null, { status: 204 }),
);
await traceCliOAuthFetch(base, true)("https://as.example/x");
expect(base).toHaveBeenCalledOnce();
expect(stderr).toHaveBeenCalledWith(`${OAUTH_DEBUG_PREFIX} -> 204\n`);
});
});

describe("oauthDebugNavigationHook", () => {
it("is undefined when the trace is off", () => {
expect(oauthDebugNavigationHook(false)).toBeUndefined();
});

it("traces the authorize URL the armed navigation prints, and only that one", async () => {
const lines: string[] = [];
const control = { armed: false };
const nav = createCliOAuthNavigation({
isTTY: false,
write: () => {},
autoOpenEnabled: false,
autoOpenControl: control,
onAuthorizationUrl: oauthDebugNavigationHook(true, (line) =>
lines.push(line),
),
});
// Disarmed: the SDK's connect-time URL nobody can complete is not traced.
nav.navigateToAuthorization(new URL("https://as.example/authorize?a=1"));
control.armed = true;
nav.navigateToAuthorization(new URL("https://as.example/authorize?a=2"));
await Promise.resolve();
expect(lines).toEqual([
`${OAUTH_DEBUG_PREFIX} authorization: open https://as.example/authorize?a=2\n`,
]);
});

it("writes to stderr by default", () => {
const stderr = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
oauthDebugNavigationHook(true)?.(new URL("https://as.example/authorize"));
expect(stderr).toHaveBeenCalledWith(
`${OAUTH_DEBUG_PREFIX} authorization: open https://as.example/authorize\n`,
);
});

it("the default sink writes one line to stderr", () => {
const stderr = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
writeOAuthDebugToStderr("x\n");
expect(stderr).toHaveBeenCalledWith("x\n");
});
});

/** A full interactive login, booted server to listed tools. */
const OAUTH_ROUND_TRIP_MS = 30_000;

describe("--oauth-debug on the CLI", () => {
let server: TestServerHttp;
let serverUrl: string;

beforeAll(async () => {
server = new TestServerHttp({
...getDefaultServerConfig(),
serverType: "streamable-http",
...createOAuthTestServerConfig({ requireAuth: true, supportDCR: true }),
});
const port = await server.start();
await waitForOAuthWellKnown(`http://localhost:${port}`);
serverUrl = `http://localhost:${port}/mcp`;
});

afterAll(async () => {
await server.stop();
clearOAuthTestData();
});

it(
"traces every step of an interactive login to stderr, credentials redacted",
async () => {
openUrlMock.mockImplementation(completeInBrowser);
const callbackPort = await freePort();
const result = await runCli(
[
"--transport",
"http",
"--server-url",
serverUrl,
"--callback-url",
`http://127.0.0.1:${String(callbackPort)}/oauth/callback`,
"--method",
"tools/list",
"--oauth-debug",
],
// Admits the interactive flow with no TTY and "opens the browser",
// which here is `completeInBrowser`.
{ env: { MCP_AUTO_OPEN_ENABLED: "true" } },
);
expect(result.exitCode).toBe(0);
const trace = result.stderr
.split("\n")
.filter((line) => line.startsWith(OAUTH_DEBUG_PREFIX));
const steps = trace.map(
(line) => line.slice(OAUTH_DEBUG_PREFIX.length + 1).split(":")[0],
);
expect(steps).toEqual(
expect.arrayContaining([
"protected-resource metadata",
"authorization-server metadata",
"client registration",
"authorization",
"token request",
]),
);
expect(result.stderr).toMatch(
/token request: POST \S+ grant_type=authorization_code code=\[redacted\]/,
);
expect(result.stderr).toMatch(/"access_token":"\[redacted\]"/);
expect(result.stdout).not.toContain(OAUTH_DEBUG_PREFIX);
},
OAUTH_ROUND_TRIP_MS,
);
});
Loading
Loading