Copilot review round 17.
`resolveClientInformation` mirrored `BaseOAuthClientProvider`, preferring the
preregistered entry. That order answers "who should I authenticate as now";
revocation asks "who minted this token", and the two diverge — the store lets a
preregistered client and an issuer-bound dynamic registration coexist, so after
a server is switched from DCR to a configured `oauth.clientId` an older DCR
grant was revoked with the configured client's credentials. RFC 7009 §2.2
answers 200 for a token the server does not recognise as the caller's, so that
reported `revoked` while the grant stayed live and the local record was already
gone — the worst available outcome.
The registration bound to the grant's issuer now wins, with the preregistered
entry as the fallback, which covers both directions: a token minted with the
configured client leaves the issuer slot empty, since the SDK writes it only
after DCR.
Stated in the comment rather than implied: this is best available evidence, not
proof. The store records client information per issuer, not per token, so
binding identity at save time remains the real fix and is a storage-shape
change beyond this PR.
Also fixed a CLI test comment still describing the pre-round-13 ordering.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 15.
The issuer check still failed open for the legacy unkeyed grant. "Unkeyed"
means its authorization server is UNKNOWN, not that the cached endpoint is
proven to own it: server metadata is a single slot a later discovery
overwrites, while a pre-SEP-2352 token survives until the first issuer-stamped
save. So a clear could post an old bearer credential to an authorization server
that never minted it.
Such a grant is now cleared without being revoked, and reported as such with a
message saying re-authorizing makes future clears revocable. The cost is that
a pre-issuer-binding entry gets no revocation — which is exactly the behavior
it had before this feature existed, so nothing regresses — and it earns
revocation back on the next authorization. Not sending is the only answer that
cannot be wrong.
Every seed in the suite is now issuer-bound, which is what a real flow writes;
the two tests that were about the legacy grant now assert the refusal, since
that is the behavior worth pinning.
Also renamed the CLI test that still described the pre-round-13 ordering.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 5.
- Deduplicating grants by token string alone could conflate two grants: a token
is only meaningful to the authorization server that minted it, so two issuers
minting the same opaque value are two grants. The second was dropped before
the issuer-mismatch check could report it, and `clear` then deleted it. The
key is now issuer+token; the ctx-less read stays suppressed only when its
token already came from a slot, which is exactly the active-issuer duplicate
and never the legacy one.
- A read failure in one issuer slot aborted `collectGrants`, so one corrupt
slot left every other revocable grant untouched while the clear deleted them
all. Each slot is now read independently and its failure carried as an
outcome alongside the grants that could still be revoked; the no-metadata and
no-endpoint returns carry them too, so a failure is never swallowed by a skip.
- The CLI no longer deduplicates its two key spellings. That could only be done
by pre-reading a single token, and `revokeStoredOAuthTokens` now enumerates
every issuer slot under a key — so a shared active token skipped the second
key entirely, taking any other issuer-bound grant under it with the local
delete. A duplicate RFC 7009 request is harmless (§2.2 makes an unknown token
a success); a missed one is the leak.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
- `revokeStoredKeys`' pre-read of `getTokens` sat outside the best-effort
catch. `getTokens` parses through `OAuthTokensSchema`, so a persisted token
that no longer validates rejected and abandoned the local delete `--relogin`
promises — over a grant that could not have been revoked anyway. Caught and
retained as a failed outcome; the clear proceeds.
- The multi-key bookkeeping was wrong twice. A token is now marked spent only
on a `revoked` outcome, so a failed or unsupported attempt no longer skips a
duplicate entry under the other key that may hold the credentials or metadata
that would have worked. And a failure now outranks an earlier success for
reporting, so a stale grant left live cannot be hidden behind the first key's
200.
- `--no-revoke` is now rejected without `--relogin`, ahead of the short-circuit
returns, matching the parser's existing stance on accepted-but-inert flags
(`--strict`). On its own it reads as "this run will not revoke anything",
true only because nothing was being cleared.
- Extracted the App draft merge as `utils/serverWithDraftSettings`, so the rule
has a test. The hook tests receive whatever they are handed and cannot tell a
draft from a persisted entry, and `App.test.tsx`'s settings harness connects
a stdio server where the OAuth section never renders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
- RFC 8414 §2 defaults an omitted `revocation_endpoint_auth_methods_supported`
to `client_secret_basic`; it does not inherit the token endpoint's list.
Inheriting made metadata advertising only `client_secret_post` there send
POST credentials to an endpoint that never advertised the method.
- The 5s timeout was inert on the web paths: `createRemoteFetch` re-issues the
call as a POST to `/api/fetch` and drops `init.signal`, and the backend's
outbound fetch gets none either, so a wedged AS could hold the teardown open
indefinitely. A wall-clock race now enforces the deadline; the signal is kept
because it genuinely cancels the direct-fetch paths.
- The settings-modal Clear read `settingsModalTarget`, which comes from the
persisted `servers` list, while edits live in `settingsDraft` until the save
debounce. Toggling the checkbox and clearing immediately used the previous
value. It now merges the draft.
- `--relogin` clears both key spellings, so it now revokes from both rather
than stopping at the first populated one — a stale entry under the other
spelling is a live grant. The normalised key goes first, matching
`findStoredServerState`, and a second key holding the same token is skipped.
- The fixture's /oauth/revoke now ENFORCES RFC 7009 §2.1 client
authentication (both RFC 6749 §2.3.1 forms), so the e2e's claim that the
Inspector authenticates correctly is actually tested; two negative cases
assert a wrong secret and an unidentified client are refused.
- Dropped the `as unknown as` casts from the tests: a typed `InspectorLogger`
double, and a spy on the real storage instead of a spread-and-cast.
- Added CLI-level tests through `runCli` for `--no-revoke`, the per-server
`oauth.revokeOnClear`, and the stderr warning, and TUI tests for the
forwarded opt-out and the rendered failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.
core/auth/revocation.ts builds and sends the request; the three clear paths
call it, so the behavior is written once — web's "Clear OAuth state and
disconnect" (both the active and stored-only paths), the TUI's Auth tab,
and the CLI's --relogin. It runs BEFORE the local clear, because the token,
the client credentials and the discovered `revocation_endpoint` all live in
the store the clear is about to empty. The request names the refresh token
when there is one: §2.1 asks the AS to also invalidate the access tokens
issued under the same grant, so one request covers both halves.
Best-effort by construction. Every path returns a TokenRevocationOutcome
rather than throwing: no advertised endpoint means nothing is sent and that
authorization server behaves exactly as before, and a network error, a
non-2xx, a 5s timeout or an unreadable store is reported and nothing more.
Forgetting the tokens is what the user asked for. `lost_authorization_state`
recovery deliberately skips it — it clears a half-finished flow to retry it,
and an authorization that never completed has no grant to revoke.
Client authentication resolves the preregistered slot first, mirroring
BaseOAuthClientProvider.clientInformation; reading only the dynamic slot
would send no authentication for a server configured with `oauth.clientId`.
The Basic credential is byte-identical to the SDK's applyBasicAuth so this
request and the token request cannot present the same secret differently.
Opt out per server with `oauth.revokeOnClear` (default on, only `false`
written to disk) or per CLI run with --no-revoke. Read at clear time rather
than connect time, so toggling needs no reconnect. Turning it off is a
testing affordance: a client that walks away still holding live tokens is a
case a server author may want to reproduce.
test-servers now advertises `revocation_endpoint` and serves POST
/oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1
(revoking a refresh token invalidates the access tokens under the same
grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as
showcase configs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
* feat(cli): catalog listing, auth flags, and OAuth browser UX
Add servers/list and servers/show, --relogin / --stored-auth-only, browser
OAuth navigation with OSC 8 links, and extract method handlers under
clients/cli/src/handlers. Tracks #1781.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): address #1782 review — relogin clear, stored-auth-only, docs
Clear both raw and normalized OAuth keys on --relogin; check the shared
store before bailing under --stored-auth-only mid-session; restore the
tests README link target; document servers/show redaction limits; keep
mcpi groundwork with TODO(#1432) markers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): gate OAuth browser open and harden #1782 review follow-ups
Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED,
move --relogin clear after validation, skip keychain on servers/list, and cover
secret redaction after rehydrate.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): silence disarmed OAuth URLs and round-3 review nits
Suppress authorize URL print when navigation is disarmed or
--stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth
headers; keep servers/show unit tests off the OS keychain.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(cli): cover withArmedAutoOpen for the ≥90 branch gate
CI failed on cliOAuth.ts branch coverage after the auto-open arming
helper landed; exercise the armed path through runCliInteractiveOAuth.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): mid-RPC unauthorized recovery and clear round-4 nits
Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let
MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the
in-process runner, reject --relogin on catalog methods, restore the
__tests__ README inventory, and narrow OAuth helpers to structural client types.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits
Reconnect before retrying after unauthorized recovery, guard non-OAuth
configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles,
and reject --relogin with stored-auth short-circuits.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits
Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true;
document --stored-auth-only for CI; redact settings.metadata; reject
--relogin for stdio; clarify AuthRecoveryRequired reconnect comments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): admit interactive OAuth when stdin is a TTY (round-7)
Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/
TTY into CliOAuthConnectOptions; rename authRequiredFailure; document
MCP_AUTO_OPEN_ENABLED=true as non-TTY admit.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): fail fast on step-up without stdin (round-8)
Decline [y/N] on readline EOF/close, require a stdin TTY for the default
step-up confirmer (force-open admit is not enough), document stderr-only
browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): allow piped step-up answers; bound silent pipes (round-9)
Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and
add a short non-TTY timeout so an open pipe that never writes cannot hang.
Clarify isTTY as admit-only; extract the readline mock factory.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): keep partial piped step-up answers (round-10)
Capture readline `line` before close so `printf y` without a newline is
not treated as decline; timeout is auth_required with a distinct message;
document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): tighten step-up timeout messaging and TTY test (round-11)
Make the TTY no-timeout assertion use a settled flag (the Promise.race
check was vacuous), say "timed out" instead of "declined" on the pipe
bound, and document that piped answers need a newline or stdin close.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>