Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
cd4ccf9
fix(auth): stop stale-snapshot writers from clobbering oauth.json
BobDickinson Sep 24, 2026
8549ef0
feat(auth): move OAuth tokens and client secrets into the secret store
BobDickinson Sep 24, 2026
e4a3df5
docs: reflect that acquired OAuth tokens now live in the secret store
BobDickinson Sep 24, 2026
65a31f4
fix(auth): address review — colon-free store ids, non-durable preserv…
BobDickinson Sep 24, 2026
10bc31a
fix(auth): address review round 2 — confirmed deletes, policy-aware c…
BobDickinson Sep 24, 2026
a837158
fix(auth): address review round 3 — file-store confirmed deletes, loc…
BobDickinson Sep 24, 2026
a7cea03
fix(auth): roll back unindexed store secrets when the residue write f…
BobDickinson Sep 24, 2026
961cb16
fix(auth): restore prior store secrets when the OAuth residue write f…
BobDickinson Sep 24, 2026
6aba8d8
fix(auth): enforce file/store consistency invariant across every comb…
BobDickinson Sep 24, 2026
64e27b8
fix(auth): make deleteClientConfigStore all-or-nothing
BobDickinson Sep 24, 2026
d6bee93
fix(auth): settle parallel store mutations before rollback; transacti…
BobDickinson Sep 24, 2026
6f70aec
fix(auth): compensate partial keychain purges; move sections descript…
BobDickinson Sep 25, 2026
39d548c
fix(auth): compensate a partially-committed keychain delete in delete…
BobDickinson Sep 25, 2026
5178060
fix(auth): lock OAuth reads against torn residue/secret joins; keep l…
BobDickinson Sep 25, 2026
a2ba182
fix(auth): handle __proto__ keys in persistence maps (prototype-pollu…
BobDickinson Sep 25, 2026
92b6620
fix(auth): round-13 polish — accurate migration warning, strict write…
BobDickinson Sep 25, 2026
f58b3d8
fix(auth): use own-property reads for untrusted map keys and reject p…
BobDickinson Sep 25, 2026
d883fad
fix(auth): validate store values in migration, blob map shapes, and t…
BobDickinson Sep 25, 2026
175eca2
fix(auth): strict OAuth hydration, schema-matched stored-value valida…
BobDickinson Sep 25, 2026
d27a6a2
fix(smoke): parse CLI error envelope from last stderr line
BobDickinson Sep 25, 2026
0785023
fix: refuse OAuth state mutations when oauth.json is unrecognized
BobDickinson Sep 25, 2026
869de23
fix: enforce a body-size cap on POST /api/storage/:storeId
BobDickinson Sep 25, 2026
581e387
fix: split registration_access_token into the secret store
BobDickinson Sep 26, 2026
6ebb203
fix(cli): surface secret-store failures instead of reporting no_store…
BobDickinson Sep 26, 2026
b43a5eb
fix(server): drive rename secret copy from strict reads; name the rig…
BobDickinson Sep 26, 2026
c6baeba
fix(cli): classify auth errors by type, not message keywords; isolate…
BobDickinson Sep 26, 2026
fe3872c
docs(auth): correct StoredOAuthClientInformation provenance comment
BobDickinson Sep 26, 2026
8d83c83
fix(storage): restore non-__proto__ Object.prototype names as valid s…
BobDickinson Sep 26, 2026
752eea6
fix(cli): rethrow permanent read failures at the --wait-for-auth dead…
BobDickinson Sep 26, 2026
2d85cea
fix(auth): compensate partial secret-store commits before degrading
BobDickinson Sep 26, 2026
1205d7d
fix(auth): keep prior residue on memory-only degrade; sweep rename de…
BobDickinson Sep 26, 2026
67ea28a
Merge remote-tracking branch 'origin/v2/main' into v2/feat/2481-oauth…
BobDickinson Sep 26, 2026
d45cb27
test: update degrade expectation for entry-level all-or-nothing revert
BobDickinson Sep 26, 2026
cabb007
test: fix semantic merge conflict in unredacted-classification test
BobDickinson Sep 26, 2026
bcaf4a2
fix(cli): bound --wait-for-auth reads by the wait deadline
BobDickinson Sep 26, 2026
944dc32
fix(auth): reject non-string secret values before they poison the store
BobDickinson Sep 26, 2026
ba3c5b5
fix(auth): share load/persist coordination per path; pin remote backe…
BobDickinson Sep 26, 2026
3abec6a
fix(auth): verify OAuth sectioned writes converge, re-applying over i…
BobDickinson Sep 26, 2026
311c615
fix(auth): restore to a fold-rule baseline on failed sectioned writes
BobDickinson Sep 26, 2026
ca122e3
docs(spec): double-failure store/file mismatch persists, not self-heals
BobDickinson Sep 26, 2026
d542c83
fix(auth): unify failure reconciliation and validate persisted token …
BobDickinson Sep 26, 2026
b560321
fix: keep unservable token payloads plaintext; serve them as no tokens
BobDickinson Sep 26, 2026
f5b232a
docs: residue is not unconditionally secret-free
BobDickinson Sep 26, 2026
6b7c8ea
fix: store partial token payloads instead of keeping them plaintext
BobDickinson Sep 26, 2026
a1914bc
fix: persist only changed secret fields on section saves
BobDickinson Sep 26, 2026
fc55e11
fix: serialize client.json combined writers and make bulk reads proto…
BobDickinson Sep 26, 2026
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ npx @modelcontextprotocol/inspector --tui # TUI
```

> [!WARNING]
> **On a machine with no OS keychain, secrets are saved to a plaintext file by default.** That covers Linux without libsecret or a Secret Service, headless and SSH sessions, Termux, and containers with a mounted volume. OAuth client secrets and stdio `env:` values then go to `~/.mcp-inspector/secrets.json`, unencrypted unless you supply a key. See [Where secrets are stored](./docs/secret-storage.md) for how to get a keychain back, encrypt the file, or keep secrets in memory only.
> **The Inspector manages secrets — OAuth tokens, OAuth client secrets, and stdio `env:` values — and stores them in the OS keychain, if available, by default.** On a machine with no keychain — Linux without libsecret or a Secret Service, headless and SSH sessions, Termux, and containers with a mounted volume — they are saved to `~/.mcp-inspector/secrets.json` instead, unencrypted unless you supply a key. See [Where secrets are stored](./docs/secret-storage.md) for how to get a keychain back, encrypt the file, or keep secrets in memory only.

> **Upgrading from v1?** Read the [v1 → v2 migration guide](./docs/v1-to-v2-migration.md) — CLI flags, the new `--config` vs. `--catalog` split, the Node engine bump, and what no longer ships.

Expand Down
6 changes: 3 additions & 3 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ Interactive OAuth (connect-time or mid-RPC) requires a TTY on **stdin or stderr*

**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 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.
**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):

Expand Down Expand Up @@ -315,7 +315,7 @@ See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAu

#### 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 file (the `oauth.json` the web backend writes) directly from disk and injects `Authorization: Bearer <token>` for `--server-url`.
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 <token>` for `--server-url`.

| Option | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -454,7 +454,7 @@ prose from stderr:
| `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, `WWW-Authenticate`, OAuth). |
| `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). |
Expand Down
80 changes: 73 additions & 7 deletions clients/cli/__tests__/error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {
formatErrorOutput,
handleError,
} from "../src/error-handler.js";
import { UnauthorizedError } from "@modelcontextprotocol/client";
import {
SecretFileLockHeldError,
SecretStoreUnavailableError,
} from "@inspector/core/auth/node/secret-store.js";
import { OAuthStateFileUnrecognizedError } from "@inspector/core/auth/node/oauth-persist-file.js";

/**
* `handleError` is the binary's last-resort error sink (wired up in
Expand Down Expand Up @@ -111,11 +117,70 @@ describe("classifyError", () => {
expect(envelope.code).toBe("schema_unportable");
});

it("classifies a WWW-Authenticate message as AUTH_REQUIRED without a status", () => {
const { exitCode } = classifyError(
new Error("Dynamic client registration failed: WWW-Authenticate Bearer"),
it("classifies a typed UnauthorizedError as AUTH_REQUIRED without a status", () => {
// Every genuine auth-required condition in the SDK throws typed
// `UnauthorizedError` (or carries a structured 401) — classification is
// by type via isUnauthorizedError, not by sniffing message keywords.
const { exitCode, envelope } = classifyError(
new UnauthorizedError("Failed to authorize"),
);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.code).toBe("auth_required");
});

it("classifies a structured 401 buried in the cause chain as AUTH_REQUIRED", () => {
// protocolEra negotiation can wrap the real 401 as a nested cause;
// isUnauthorizedError walks the chain.
const err = new Error("negotiation failed", {
cause: Object.assign(new Error("upstream"), { status: 401 }),
});
const { exitCode } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
});

it("does not classify prose mentioning OAuth as AUTH_REQUIRED", () => {
// The retired keyword heuristic (/…|OAuth/i) reported errors like this
// one — a programming/usage failure — as "re-authorize", exit 3. It is
// a plain error: exit 1, and the caller reads the message.
const { exitCode, envelope } = classifyError(
new Error("OAuth storage is required for this operation."),
);
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.code).toBe("error");
});

it("classifies SecretStoreUnavailableError as store_unavailable, exit 1", () => {
const { exitCode, envelope } = classifyError(
new SecretStoreUnavailableError("keychain probe failed"),
{ url: "https://x.example/mcp" },
);
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.code).toBe("store_unavailable");
expect(envelope.url).toBe("https://x.example/mcp");
});

it("does not let OAuth wording in a lock-held store error read as auth_required", () => {
// SecretFileLockHeldError messages mention the OAuth state file. The
// typed operational branch classifies it as store_unavailable — telling
// the user the store is busy, not to re-authorize.
const { exitCode, envelope } = classifyError(
new SecretFileLockHeldError(
"Could not lock the OAuth state file: held by another process",
),
);
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.code).toBe("store_unavailable");
});

it("classifies an unrecognized OAuth state file as oauth_state_unrecognized", () => {
// Repair-the-file advice, not re-authorize (auth_required) and not
// retry-later (store_unavailable): the error message tells the user
// exactly what to do, and the code lets a script branch on it.
const { exitCode, envelope } = classifyError(
new OAuthStateFileUnrecognizedError("/tmp/oauth.json", "save"),
);
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.code).toBe("oauth_state_unrecognized");
});

it("classifies ENOTFOUND / fetch failed as UNREACHABLE", () => {
Expand Down Expand Up @@ -379,12 +444,13 @@ describe("envelope URL redaction", () => {
});

it("classifies on the unredacted text", () => {
// The only auth signal is inside a parameter value that redaction
// replaces; classifying the redacted copy would fall through to USAGE.
// The only classification signal (an UNREACHABLE_PATTERN match) is
// inside a parameter value that redaction replaces; classifying the
// redacted copy would fall through to USAGE.
const { exitCode, envelope } = classifyError(
new Error("Rejected https://srv.example/cb?token=invalid_token"),
new Error("Rejected https://srv.example/cb?token=ECONNREFUSED"),
);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(exitCode).toBe(EXIT_CODES.UNREACHABLE);
expect(envelope.message).toBe(
"Rejected https://srv.example/cb?token=%5BREDACTED%5D",
);
Expand Down
Loading
Loading