The SDK shipped 2.0.0 final on 2026-07-27. Shipping the Inspector against a prerelease of its core protocol library is a supply-chain and reproducibility liability we no longer have a reason to carry: a beta is eligible for unpublish and deprecation in a way a stable release is not. Bumped in the root package.json only — client, core, server, server-legacy. Node resolution walks up, so the root install already serves every client; a per-client declaration would install a second copy that drifts from it (#1970). Verified after install: one copy of each at 2.0.0, no clients/*/node_modules/ @modelcontextprotocol/* at all. ext-apps is a separate package on its own ^1.7.4 line and is untouched. The beta.5 → 2.0.0 delta is seven commits. Reviewed each for call-site impact: - core/src/schemas.ts (+16/-16) is doc-comment only — every hunk rewrites a spec URL from a commit-pinned GitHub blob to the published 2026-07-28 page. No schema surface change, so nothing in core/mcp or core/json is affected. - client 2.0.0 wants zod ^4.2.0; we are on ^4.4.3, so no zod move and no risk of the #1896 dual-copy tsc heap blowup. verify:dep-lockstep stays green and the lockfile delta is exactly the four packages with no transitive churn. - fix(validators) honors declared draft-07/06 JSON Schema dialects instead of rejecting them, which can only widen the set of tool schemas we accept. - The SSE keep-alive and legacyWrap changes are server-side, exercised by the test servers rather than by client code. The one change with real behavioral reach is #2564: the negotiation probe now classifies HTTP 401/403 as an auth failure instead of legacy-era evidence. That is the upstream fix #1807 is blocked on, and it is left for that issue — the directAuthRecovery clause in inspectorClient.ts still intercepts the challenge before the classifier sees it, so this bump does not change connect behavior. #1807 can now delete the workaround and lean on the SDK verdict, but doing it here would smuggle an auth change into a dependency bump. Also corrected the two auth as-built specs, which still claimed beta.4 — already stale before this change. v2_new_spec_impact.md is deliberately left alone: it is a dated pre-upgrade analysis, and editing its premises would rewrite the record rather than update it. npm run ci passes end to end: validate, the per-file >=90 coverage gate, verify:build-gate, all six smokes (including web:app driving connect -> open app -> data-app-status="ready"), and 472 Storybook tests. Closes #1988 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com>
21 KiB
Inspector V2 Auth — Authorization hardening (MCP 2026-07-28)
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 |