Repository navigation
Description
Which version line?
v2 — current (@modelcontextprotocol/inspector@latest)
Which client?
All / shared core — the predicate lives in core/, and web, CLI and TUI all consume it.
Inspector version
2.2.0
Node version
v24.14.1
Operating system
macOS (Darwin 25.2.0), Chrome
Transport
Streamable HTTP
MCP server under inspection
A local Rails/Doorkeeper-backed MCP server at https://localhost:3000/mcp — protocol era auto, OAuth yes (authorization_code + PKCE, confidential client, 900s access tokens).
Honest scoping: I found this by reading core/ while debugging persisted OAuth state, not by watching it fail against a live opaque-token AS. The reproduction below is exact at unit level (a test that fails on v2/main and passes with the fix); the end-to-end sequence is derived from the call sites, which I've cited so you can judge it yourself.
Steps to reproduce
- Connect to an OAuth-protected Streamable HTTP MCP server whose authorization server issues opaque (non-JWT) access tokens — the default for Doorkeeper, Keycloak configured for opaque tokens, GitHub-style
gho_…, and many others. - Authorize. Storage records something like
{ access_token: "<opaque>", token_type: "Bearer", expires_in: 900 }. - Wait until that token has expired server-side.
- Reconnect, or make any call that now 401s.
Expected behavior
The resource server rejected the credential, so the Inspector re-authorizes — AuthRecoveryRequiredError → prepareOAuthRedirect({ authKind: "reauth" }) in the web client, the equivalent interactive flow in CLI/TUI.
Actual behavior
checkAuthChallengeSatisfied() reports the challenge as already satisfied, so the caller reconnects with the same expired token. From clients/web/src/App.tsx (connect path):
if (err instanceof AuthRecoveryRequiredError) {
if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
connectStartRef.current = Date.now();
await client.connect(); // ← retried with the same dead token
return;
}
prepareOAuthRedirect({ ..., authKind: "reauth", ... }); // ← never reached
}The server 401s again and the loop repeats: no re-auth redirect, no ReAuthBanner. The only way out is Clear OAuth state, which deletes the whole server key from oauth.json — including preregisteredClientInformation, i.e. the client id and secret the user typed in.
The same short-circuit is in clients/cli/src/cliOAuth.ts (lines 241 and 252 — an early return instead of authorizing) and clients/tui/src/App.tsx (lines 701, 720).
Root cause
core/mcp/oauthManager.ts (~517):
if (challenge.reason !== "insufficient_scope") {
return challenge.reason === "token_expired" && isAccessTokenUsable(tokens);
}core/auth/connection-state.ts:28:
export function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean {
if (!tokens?.access_token) return false;
return !isJwtExpired(tokens.access_token);
}core/auth/ema/jwt.ts:33 — isJwtExpired returns false when jwtExpiresAtMs() is undefined, i.e. whenever the token is not a JWT or carries no exp:
const exp = jwtExpiresAtMs(token);
if (exp === undefined) return false;So "we cannot tell" is read as "still valid", and an opaque token is unexpirable by construction. Nothing else can correct it: expires_in never becomes an absolute expiry anywhere in core/ (it is only carried across the wire in core/mcp/remote/types.ts and core/mcp/remote/node/tokenAuthProvider.ts), and no expires_at is recorded at save time.
Two things widen the blast radius:
- A bare 401 maps to
token_expired(core/auth/challenge.ts:250, "Bare 401 without a Bearer error code — treat as expired token"), so this is not limited to servers that sendWWW-Authenticate: … error="invalid_token". - The real recovery in
handleAuthChallenge(silent refresh, then interactive) is never reached, because callers consultcheckAuthChallengeSatisfiedfirst — exactly as that method's own doc comment says they do.
Suggested fix
Keep isAccessTokenUsable optimistic — it is the right default for displaying authorization state, and flipping it would break every opaque-token server's "Authorized" indicator. Add a pessimistic sibling for the one moment where a resource server has explicitly disagreed with us:
export function isAccessTokenProvablyUnexpired(
tokens: OAuthTokens | undefined,
): boolean {
if (!tokens?.access_token) return false;
if (jwtExpiresAtMs(tokens.access_token) === undefined) return false;
return !isJwtExpired(tokens.access_token);
}and use it in checkAuthChallengeSatisfied. Absence of local evidence then no longer outvotes the server's verdict, while a JWT that is provably still alive keeps its cheap short-circuit.
Note this flips one existing expectation, which currently pins the buggy behaviour with an opaque token:
it("returns true for token_expired when a usable access token exists", async () => {
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok", // ← opaque
token_type: "Bearer",
});
expect(
await manager.checkAuthChallengeSatisfied({ reason: "token_expired" }),
).toBe(true);
});Already prototyped a fix?
Yes — per CONTRIBUTING.md I am not sending a diff. The prompt that produces the change:
In
core/auth/connection-state.ts, add an exportedisAccessTokenProvablyUnexpired(tokens)next toisAccessTokenUsable. It returns true only when the access token is a JWT whoseexpis still in the future, using the existingjwtExpiresAtMsandisJwtExpiredhelpers from./ema/jwt.js(so the current 60s skew is preserved); a missing token, or one with no parseableexp, returns false. Document why the two predicates differ: storage keeps no absolute expiry for opaque tokens, becauseexpires_inis relative to an issuance instant that is never recorded, soisAccessTokenUsabletreats an unknown expiry as usable — correct for displaying authorization state, wrong once a resource server has rejected the credential.In
core/mcp/oauthManager.ts, makecheckAuthChallengeSatisfieduse the new predicate for thetoken_expiredbranch instead ofisAccessTokenUsable, and update its doc comment:token_expiredis now satisfied only by a provably unexpired JWT; an opaque token has no local expiry evidence, so the server's verdict stands and re-authorization proceeds.insufficient_scope,invalid_tokenandunauthorizedare unchanged.Update
clients/web/src/test/core/mcp/oauthManager.test.ts: the existing "returns true for token_expired when a usable access token exists" case uses an opaque"tok"and pins the old behaviour — rewrite it around a JWT with a futureexp, and add siblings for an expired JWT and for an opaque token (both false), the latter commented as the regression it guards. Add ajwtWithExphelper matching the one in the EMA tests. Inclients/web/src/test/core/auth/connection-state.test.ts, add a describe block for the new predicate: future-exp JWT true; expired JWT false; inside the skew window false; opaque token false whileisAccessTokenUsableis true for the same token; no access token false.
How I verified it
cd clients/web && npx vitest run --project=unit src/test/core/auth/connection-state.test.ts src/test/core/mcp/oauthManager.test.ts— 116 passed.- Full web unit suite (
npm run testinclients/web) — 273 files / 4339 tests passed. (One run had an unrelated flake inAppControls.test.tsx"does not invoke onSelectApp when the already-selected app is clicked"; it passes in isolation both with and without the change, and a second full run was green.) prettier --checkandeslintclean on the four touched files.- Confirmed the new opaque-token test fails on unmodified
v2/main(it returnstrue) and passes with the change.
A branch with the change and tests, if it is useful as reference: https://github.com/Oldharlem/inspector/tree/v2/fix/opaque-token-replay-on-token-expired (please don't treat it as a PR — I'm following the issues-only policy).
Related, lower priority — say the word and I'll file it separately
Recovering from bad cached auth state is currently all-or-nothing: "Clear OAuth state" removes the entire server key (clearServerState in core/auth/store.ts), so a user re-testing an auth server loses the preregistered client id/secret every time. A "re-run discovery / drop tokens, keep client credentials" action would cover that, and MCP_STORAGE_DIR — which does give a clean ephemeral store, and which I verified works for the web client — is documented only in clients/cli/README.md and the v1→v2 migration table, not where a web user would find it.
Before you submit
- I searched existing issues and this is not a duplicate.
- This is not a security vulnerability report.
Activity
cliffhall commentedon Aug 19, 2026
Triage: Priority Medium (total 8)
buglabelThanks for the explicit scoping note on what was reproduced at unit level versus derived from the call sites — that distinction is genuinely useful at triage.
Board: #28, Status
Incoming(awaiting maintainer review — no milestone yet).fix(auth): don't answer token_expired from an unknowable local expiry
fix(oauth): refresh an expired token behind an insufficient_scope cha…