Inspector V2 Auth
August 12, 2026 · View on GitHub
Brief | V1 Problems | V2 Scope | V2 Tech Stack | V2 UX | V2 Auth | V2 New Spec Impact
Overview | EMA / XAA | Hardening | Mid-session | Smoke testing | SDK consolidation
As-built status for aligning Inspector with the authorization hardening SEPs in the MCP 2026-07-28 release — tracked by #1527.
Inspector is on @modelcontextprotocol/client 2.0.0. Connect-time standard OAuth is delegated to SDK auth(); Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See SDK consolidation.
Policy: SEP behavior that can be automated is covered (or should be covered) by unit/integration tests. Hosted-IdP smoke in v2_auth_smoke_testing.md is complementary for real providers — it is not required for every SEP once CI covers the requirement.
Status vs #1527
| SEP | Topic | Status | How / where |
|---|---|---|---|
| SEP-2468 | RFC 9207 iss on auth responses | Done | Host parses iss from callback (web App.tsx, node loopback server); completeOAuthFlow(code, iss?) → mcpAuth → SDK auth() validates (#1687). EMA IdP leg 1 forwards iss the same way. |
| SEP-837 | DCR application_type | Done | BaseOAuthClientProvider declares application_type: "native" for all clients (localhost Inspector = RFC 8252 native). #1625 / #1694. |
| SEP-2352 | Credentials bound to AS issuer | Done | OAuthStorage byIssuer + activeIssuer; provider invalidateCredentials / discoveryState; lazy migration from legacy blobs. Same PRs as SEP-837. |
| SEP-2207 | OIDC refresh / offline_access | Done (SDK + EMA) | Standard OAuth: SDK auth() / determineScope after v2 upgrade (#1624 / #1688). EMA leg 1: IDP_OIDC_SCOPES = "openid offline_access". Do not assume a refresh token is issued. |
| SEP-2350 | Step-up scope union | Done | Owned by mid-session (#1526 closed; mid-session spec): handleAuthChallenge, SDK scope helpers, web remote auth-state, TUI/CLI confirm, onInsufficientScope setting, step-up modal. [#1527] deferred this SEP to that track. |
| SEP-2351 | Stable .well-known discovery | Done (via SDK) | No Inspector-built discovery URLs — discoverOAuthProtectedResourceMetadata / discoverAuthorizationServerMetadata (and auth()) from the v2 client. |
Related closed work: SDK upgrade #1624; storage/UX hardening #1625; mid-session + step-up #1526; shared web RemoteOAuthStorage #1548.
Per-SEP automated test coverage
| SEP | Automated coverage | Gap? |
|---|---|---|
| 2350 step-up | Covered — mid-session remote/direct e2e + challenge/scopes/oauthManager + CLI/TUI oauth tests | No (hosted smoke is complementary, not a gap) |
837 application_type | Covered — providers.test.ts asserts "native"; DCR exercised in OAuth e2e | No |
| 2352 issuer binding | Covered — byIssuer persistence + migration e2e (discovery → issuer B forces re-authorize) | No |
2468 iss | Covered — forwarding + standard-OAuth reject e2e (mismatch / missing when required) | No |
2207 refresh / offline_access | Covered — refresh-after-401 e2e + authorize offline_access assert | No |
| 2351 discovery | Covered — fetchFn asserts PRM + AS well-known URL shapes | No |
Hosted smoke (v2_auth_smoke_testing.md) is complementary for real IdP quirks. It does not close the CI gaps below.
Remaining changes — explicit plan
SEP product code for the six hardening SEPs is already shipped. Steps 1–5 below are implemented on branch v2/auth-sdk-consolidation (tests + isUnauthorizedError unwrap). Step 6 (close #1527) waits on PR merge.
Nothing below is optional: each item is either do (with how) or do not (with why).
Decisions
| Item | Kind | Decision | Status |
|---|---|---|---|
| SEP-2468 reject-path e2e | Test only | Do | Done — local AS returns iss; e2e happy/mismatch/missing |
SEP-2207 offline_access assert | Test only | Do | Done — authorize scope includes offline_access when advertised |
| SEP-2351 discovery URL asserts | Test only | Do | Done — fetchFn tracker asserts PRM + AS well-known paths |
| SEP-2352 AS-migration e2e | Test only | Do | Done — discovery flip to issuer B forces re-authorize URL |
EraNegotiationFailed → 401 unwrap | Product code | Do | Done — isUnauthorizedError walks cause / data.cause |
| Per-SEP smoke scenarios | — | Do not | Unchanged — automatable → CI |
Ordered work (one PR or a tight PR series)
A. Tests only (steps 1–4) — no SEP feature work
These close #1527 acceptance gaps. Fixture/harness edits under test-servers/ and test helpers are allowed solely to make the asserts possible; they are not product features.
1. SEP-2468 — standard-OAuth iss reject e2e (test)
Gap: Local AS never advertises authorization_response_iss_parameter_supported and never returns iss. completeOAuthAuthorization returns only code. EMA IdP tests already cover missing/mismatched iss; standard InspectorClient.completeOAuthFlow does not.
Changes (tests + fixtures only):
test-servers/src/test-server-oauth.ts- AS metadata:
authorization_response_iss_parameter_supported: true completeAuthorizationRedirect: appendiss=<issuer>(same string as metadataissuer, no trailing slash)
- AS metadata:
clients/web/src/test/integration/helpers/oauth-client-fixtures.ts- Extend
completeOAuthAuthorizationto return{ code, iss }(or add a sibling helper). Update call sites that need the new shape.
- Extend
inspectorClient-oauth-e2e.test.ts(new describe):- Happy:
completeOAuthFlow(code, correctIss)→ tokens stored / connect works - Mismatch:
completeOAuthFlow(code, "https://evil.example")→ rejects (IssuerMismatchError/ message match); no tokens stored - Missing when required:
completeOAuthFlow(code)with noiss→ rejects; no tokens stored
- Happy:
Done when: those three cases pass under the existing streamable-HTTP (or both) transport matrix entry used by neighboring OAuth e2e.
2. SEP-2207 — offline_access on standard authorize (test)
Gap: SDK determineScope appends offline_access only when AS scopes_supported includes it and client metadata advertises refresh_token (Inspector already does). Default test AS scopes_supported is ["mcp"] only, so authorize never requests offline_access in CI.
Changes (tests + fixtures only):
- OAuth e2e fixture:
scopesSupported: ["mcp", "offline_access"](and keepsupportRefreshTokens: true). - After
authenticate(), assert authorize URLscopequery param includesoffline_access. - Keep existing refresh-after-401 e2e as the refresh-grant proof (no change required there beyond not regressing).
Done when: one e2e asserts the authorize scope string; refresh-after-401 still green.
3. SEP-2351 — discovery URL shape asserts (test)
Gap: fetchFn integration only checks that some well-known / oauth URLs were hit.
Changes (tests only): In that same test (or a focused sibling), after authenticate() (discovery complete), assert the tracker contains:
/.well-known/oauth-protected-resource(PRM; path-aware variant if the MCP URL has a path)/.well-known/oauth-authorization-server(and/or the SDK’s documented openid-configuration fallbacks if the AS 404s the first — our combined AS serves oauth-authorization-server, so assert that one)
Done when: URL path assertions fail if Inspector/SDK ever regresses to a non-SEP-2351 discovery shape.
4. SEP-2352 — AS migration on connect path (test)
Gap: We prove byIssuer persistence and invalidateCredentials mapping. We do not prove that when PRM’s authorization_servers[0] changes, stamped credentials for the old issuer are not silently reused (#1527 AC3).
Changes (tests + fixtures only):
- Complete static-client OAuth against local AS (issuer A); confirm tokens under
byIssuer[A]. - On the next
authenticate(), force discovery to resolve issuer B (recommended:fetchFnthat rewrites PRMauthorization_serversto a second local AS URL, or restart/protected-resourcemode pointing at a different AS). Second AS can be a secondTestServerHttpor a stub that only serves AS metadata + authorize/token. - Assert outcomes (all required):
- Old issuer-A tokens are not used to connect as authorized against B
- Client must obtain a new authorization URL (or fail closed for static client against unknown AS) — not a silent
AUTHORIZEDwith A’s tokens - Storage does not leave active bearer state implying B while only A’s slot has tokens
Prefer exercising real auth() + provider discardIfIssuerMismatch / discovery-state path over a pure storage unit test — AC3 is about migration detection, not blob shape.
Done when: one integration test encodes the migration; silent reuse would fail the test.
B. Product code (step 5) — only non-test work in this plan
5. EraNegotiationFailed unwrap (product code + unit tests)
Gap: Under protocolEra: "auto" | "modern", a negotiation probe that hits HTTP 401 can throw SdkError(SdkErrorCode.EraNegotiationFailed) with the real UnauthorizedError at error.cause (or error.data.cause — match whatever the installed SDK beta actually sets; assert in the unit test). isUnauthorizedError in core/auth/utils.ts does not walk that chain, so App / connect OAuth UX may never start authenticate().
Changes:
- Extend
isUnauthorizedErrorto return true when the error (or a single-level / recursivecause) is an unauthorized 401 — including SDKUnauthorizedErrorand wrappedEraNegotiationFailed. - Unit tests in
clients/web/src/test/core/auth/utils.test.ts(and any thin re-export tests): bare 401 still true;SdkError(EraNegotiationFailed, { cause: UnauthorizedError })true; unrelatedSdkErrorfalse. - Prefer fixing the shared detector over scattering unwrap logic in
oauthManager/App.tsx.
Done when: unit coverage proves wrapped negotiation 401s are recognized; no behavior change for legacy (unwrapped) 401s.
Ship in the same PR series as steps 1–4 (or immediately adjacent). Not a #1527 SEP AC — do it anyway because era selection already exists.
C. Issue hygiene
6. Close #1527 (do, after tests 1–4; product step 5 may land with them)
- Land steps 1–5 (single PR preferred: “auth hardening CI gaps + era 401 unwrap”).
- Comment on #1527 with links to the PR and this plan; mark ACs 1–5 satisfied by existing implementation + new CI tests (AC6 smoke-per-SEP: waived per policy above — state that explicitly in the closing comment). Note step 5 as a related product fix, not a SEP AC.
- Close #1527 and move the board card to Done.
- Do not leave #1527 open as an unbounded “nice tests” bucket.
Out of scope (hard exclusions)
- Mid-session / step-up reimplementation (#1526 done)
- Web remoting redesign / deleting
mcpAuth - Client-credentials grant (#1225)
- Rewriting v2_auth_smoke_testing.md with one scenario per SEP
- EMA leg-3 SDK swap (blocked on SDK
resource/scope— see SDK consolidation)
Per-SEP detail (as-built)
SEP-2468 — iss validation
- SDK: validates authorization-response
issagainst metadata when the host passes it intoauth(). - Inspector: parses
issfrom the callback query string on web and TUI/CLI loopback; forwards throughOAuthManager.completeOAuthFlow/ EMA IdP completion. - Tests: forwarding covered; standard-OAuth reject-path e2e is plan step 1.
SEP-837 — application_type
- Inspector: always
"native"in DCR client metadata (loopback redirects). - UX:
ClientSettingsFormsurfaces DCRRegistrationRejectedError; DCR marked deprecated-in-favor-of-CIMD (SEP-991). - Tests:
providers.test.tsassertsapplication_type: "native".
SEP-2352 — issuer-bound credentials
- Storage:
byIssuer[issuer]for client info / tokens / registration kind;activeIssuerfor ctx-less bearer reads; re-attachissuerafter Zod parse strips it; lazy legacy fallback until first stamped save. - Provider:
discoveryState/saveDiscoveryState,invalidateCredentials(scope). - SDK: stamps
ctx.issuer,discardIfIssuerMismatchon read. - Tests: storage/provider unit + e2e
byIssuerpersistence; AS-migration connect fixture is plan step 4 (required for #1527 AC3).
SEP-2207 — refresh tokens
- Standard OAuth: SDK scope determination / refresh after v2 upgrade.
- EMA: IdP authorize requests
openid offline_access; IdP session refresh inrefreshIdpOidcSession. - Mid-session: silent refresh / EMA re-mint; never assume a refresh token exists.
- Tests: refresh-after-401 e2e; standard-OAuth
offline_accessauthorize assert is plan step 2.
SEP-2350 — step-up scope accumulation
| Path | Behavior |
|---|---|
| Web remote | authChallengeFetch → AuthChallenge → browser handleAuthChallenge → optional step-up modal → pushAuthState |
| TUI/CLI direct | SDK silent 401 / streamable-HTTP 403 retry where onInsufficientScope allows; Inspector confirm UX + EMA branch |
| All | 403 unions scopes; 401 replaces; forceReauthorization when refresh cannot widen; scopes re-exported from SDK |
See v2_auth_mid_session.md. Hosted UX: smoke §5 (complementary to CI).
SEP-2351 — discovery suffix
Inspector calls SDK discovery helpers only (core/auth/discovery.ts, cimd.ts, EMA resourceContext.ts, and inside auth()). No local well-known URL builder. URL-shape asserts are plan step 3.
Architecture (current)
Connect-time standard OAuth EMA Web remote MCP
─────────────────────────── ─── ──────────────
SDK auth() via mcpAuth() Leg 1: idpOidc + SDK Token stub + intercept
BaseOAuthClientProvider Leg 2: SDK ID-JAG helper Browser owns OAuth
byIssuer OAuthStorage Leg 3: local until SDK auth-state hot-swap
completeOAuthFlow(code, iss?) grows resource/scope
TUI/CLI: live provider + SDK transport silent retry; handleAuthChallenge for EMA / interactive defer
Non-goals
- Reimplementing authorization-server or resource-server wire formats.
- v1 / v1.5 backport.
- Client credentials grant (#1225).
- Full 2026-07-28 era/
versionNegotiationmigration (era UI already exists; plan step 5 only fixes 401 detection under auto/modern — not a default-era flip). AuthRecoveryTransportor client-side fetch intercept on TUI/CLI SDK transport.- Unifying web remote and TUI/CLI transport wrappers.
- Per-SEP smoke scenarios for behavior already covered by automated tests.
Normative references
- MCP authorization (draft — 2026-07-28 RC target)
- MCP authorization (2025-11-25 — current stable)
- 2026-07-28 RC — Authorization Hardening
- RFC 9207 — Authorization Server Issuer Identification
- Tracking issue: #1527
Related specs
| Doc | Relationship |
|---|---|
| v2_auth_mid_session.md | SEP-2350 runtime + recovery UX (done) |
| v2_auth_sdk_consolidation.md | What auth code sits on the v2 SDK vs Inspector |
| v2_auth_ema.md | EMA legs; SEP-2207 IdP scopes |
| v2_auth_smoke_testing.md | Hosted-IdP manual verification (complementary to CI) |
| v2_storage.md | Shared oauth.json / issuer-keyed credentials |