Auth hardening CI gaps + SDK consolidation: EMA leg 2 / scopes (#1527) (#1725)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Close #1527 acceptance gaps with automated tests (SEP-2468 iss, SEP-2207 offline_access, SEP-2351 discovery shapes, SEP-2352 AS-migration, EraNegotiationFailed→401 unwrap); lean EMA leg 2 and scope helpers on the v2 SDK; drop deprecated TS baseUrl. Reviewed LGTM.
This commit is contained in:
parent
ec185f1053
commit
a71e6d2012
1 changed files with 1513 additions and 852 deletions
|
|
@ -1,229 +1,248 @@
|
|||
# Inspector V2 Auth — Authorization hardening (MCP 2026-07-28)
|
||||
|
||||
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md)
|
||||
#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | Hardening | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md)
|
||||
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md)
|
||||
|
||||
Align Inspector v2 with the **authorization hardening** in the upcoming MCP **`2026-07-28`** specification release — six SEPs that tighten OAuth/OIDC client behavior for MCP's single-client, many-server deployment pattern.
|
||||
#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | Hardening | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md)
|
||||
|
||||
## Summary
|
||||
As-built status for aligning Inspector with the **authorization hardening** SEPs in the MCP **`2026-07-28`** release — tracked by [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527).
|
||||
|
||||
The [2026-07-28 release candidate](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) adds six authorization SEPs. All are **OAuth client** requirements. Inspector implements OAuth through a mix of SDK delegation (connect-time standard OAuth via `auth()`) and local code (EMA wire, storage, web callback, mid-session recovery).
|
||||
Inspector is on `@modelcontextprotocol/client` **2.0.0-beta.4**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md).
|
||||
|
||||
**Strategy:** Upgrade to the v2 TypeScript SDK (`@modelcontextprotocol/client`) when its auth-hardening PRs land. Do **not** reimplement SDK OAuth logic in Inspector — delegate connect-time standard OAuth to the SDK and wire the gaps (callback parameters, storage shape, client type). **Mid-session authorization** is a separate, near-term track ([Mid-session auth](v2_auth_mid_session.md)); this doc covers connect-time and storage hardening that mid-session builds on.
|
||||
**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](v2_auth_smoke_testing.md) is complementary for real providers — it is **not** required for every SEP once CI covers the requirement.
|
||||
|
||||
## Implementation status (#1625, on `@modelcontextprotocol/client` 2.0.0-beta.4)
|
||||
---
|
||||
|
||||
The SDK upgrade ([#1624](https://github.com/modelcontextprotocol/inspector/issues/1624)) and RFC 9207 `iss` forwarding ([#1687](https://github.com/modelcontextprotocol/inspector/pull/1687)) landed first. This section records the **as-built** behavior of the storage re-key + SEP-837/2350/2352 UX ([#1625](https://github.com/modelcontextprotocol/inspector/issues/1625)); it supersedes the forward-looking prose below where they differ.
|
||||
## Status vs [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527)
|
||||
|
||||
- **SEP-2352 — issuer-bound credentials (done).** `ServerOAuthState` now nests issuer-bound credentials under `byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }`, with an `activeIssuer` pointer answering the transport's ctx-less per-request bearer read. The SDK v2 `auth()` drives the whole flow — it builds `infoCtx = { issuer }`, stamps `issuer` on everything it saves, and calls `discardIfIssuerMismatch` on reads — so the Inspector's job is to **honor `ctx.issuer`** and **round-trip the stamp**. A latent bug was that `OAuthStorageBase` re-parsed tokens/client-info through `OAuthTokensSchema`/`OAuthClientInformationSchema`, which **strip** the non-RFC `issuer` field; the store now re-attaches it from the `byIssuer` key. `BaseOAuthClientProvider` implements `discoveryState()`/`saveDiscoveryState()` (activating the SDK's callback-leg `AuthorizationServerMismatchError` binding check) and `invalidateCredentials(scope)`. Migration is **lazy**: a legacy pre-1625 blob's top-level `clientInformation`/`tokens` are returned as an unkeyed fallback until the first issuer-stamped save promotes them into `byIssuer` — existing web/TUI/CLI sessions survive the upgrade.
|
||||
- **SEP-837 — `application_type` (done).** The shared `BaseOAuthClientProvider` declares `application_type: "native"` for **all** environments: the Inspector is a locally-hosted app reached over `localhost`, so it is a native client per RFC 8252 (the SDK also infers `"native"` from loopback `redirect_uris`). This supersedes the "web = `web`" split in the per-SEP table below. `ClientSettingsForm` renders DCR `RegistrationRejectedError` details and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status.
|
||||
- **SEP-2350 — step-up union (done).** The scope-union machinery (`core/auth/scopes.ts`, `handleAuthChallenge`) predates this card; #1625 adds the UX: a **per-server** `onInsufficientScope` (`reauthorize | throw`) setting (`ServerSettingsForm` → `StoredMCPServer.oauth` → `StreamableHTTPClientTransport`), and a scope-union visualization in `StepUpAuthModal` that tags each union scope `new` vs `already granted`.
|
||||
- **`mcpAuth` (simplified, not deleted).** `core/auth/mcpAuth.ts` is now a thin forwarder to SDK `auth()` — the hand-rolled `authorizeWithoutRefresh` (which mishandled issuer stamping) was deleted and `forceReauthorization`/`skipIssuerMetadataValidation` are forwarded natively, so both the initial and step-up paths get SEP-2352 stamping. `core/auth/scopes.ts` is **retained** (still the union source; the SDK's `computeScopeUnion` export is a future cleanup).
|
||||
- **Vocabulary.** `OAuthStep` gains `cimd_fetch` / `issuer_comparison` / `scope_step_up` (the manager records `scope_step_up` on a step-up redirect), and the Network tab labels `auth`-category requests by OAuth phase (Discovery / Registration / Authorize / Token).
|
||||
- **Deferred to the era-aware card.** The `SdkError(EraNegotiationFailed) → UnauthorizedError` unwrap and the `finishAuth(URLSearchParams)` whole-params form only fire under `auto`/`pin` negotiation; the Inspector is pinned to `versionNegotiation: 'legacy'`, so this lands with the era-aware connection model.
|
||||
| SEP | Topic | Status | How / where |
|
||||
| -------------------------------------------------------------------------------------- | -------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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](https://github.com/modelcontextprotocol/inspector/pull/1687)). EMA IdP leg 1 forwards `iss` the same way. |
|
||||
| **[SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)** | DCR `application_type` | **Done** | `BaseOAuthClientProvider` declares `application_type: "native"` for all clients (localhost Inspector = RFC 8252 native). [#1625](https://github.com/modelcontextprotocol/inspector/issues/1625) / [#1694](https://github.com/modelcontextprotocol/inspector/pull/1694). |
|
||||
| **[SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)** | OIDC refresh / `offline_access` | **Done** (SDK + EMA) | Standard OAuth: SDK `auth()` / `determineScope` after v2 upgrade ([#1624](https://github.com/modelcontextprotocol/inspector/issues/1624) / [#1688](https://github.com/modelcontextprotocol/inspector/pull/1688)). EMA leg 1: `IDP_OIDC_SCOPES = "openid offline_access"`. Do not assume a refresh token is issued. |
|
||||
| **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** | Step-up scope union | **Done** | Owned by mid-session ([#1526](https://github.com/modelcontextprotocol/inspector/issues/1526) closed; [mid-session spec](v2_auth_mid_session.md)): `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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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](https://github.com/modelcontextprotocol/inspector/issues/1624); storage/UX hardening [#1625](https://github.com/modelcontextprotocol/inspector/issues/1625); mid-session + step-up [#1526](https://github.com/modelcontextprotocol/inspector/issues/1526); shared web `RemoteOAuthStorage` [#1548](https://github.com/modelcontextprotocol/inspector/issues/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](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):**
|
||||
|
||||
1. `test-servers/src/test-server-oauth.ts`
|
||||
- AS metadata: `authorization_response_iss_parameter_supported: true`
|
||||
- `completeAuthorizationRedirect`: append `iss=<issuer>` (same string as metadata `issuer`, no trailing slash)
|
||||
2. `clients/web/src/test/integration/helpers/oauth-client-fixtures.ts`
|
||||
- Extend `completeOAuthAuthorization` to return `{ code, iss }` (or add a sibling helper). Update call sites that need the new shape.
|
||||
3. `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 no `iss` → rejects; **no tokens stored**
|
||||
|
||||
**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):**
|
||||
|
||||
1. OAuth e2e fixture: `scopesSupported: ["mcp", "offline_access"]` (and keep `supportRefreshTokens: true`).
|
||||
2. After `authenticate()`, assert authorize URL `scope` query param includes `offline_access`.
|
||||
3. 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):**
|
||||
|
||||
1. Complete static-client OAuth against local AS (issuer A); confirm tokens under `byIssuer[A]`.
|
||||
2. On the next `authenticate()`, force discovery to resolve issuer B (recommended: `fetchFn` that rewrites PRM `authorization_servers` to a second local AS URL, **or** restart/`protected-resource` mode pointing at a different AS). Second AS can be a second `TestServerHttp` or a stub that only serves AS metadata + authorize/token.
|
||||
3. 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 `AUTHORIZED` with 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:**
|
||||
|
||||
1. Extend `isUnauthorizedError` to return true when the error (or a single-level / recursive `cause`) is an unauthorized 401 — including SDK `UnauthorizedError` and wrapped `EraNegotiationFailed`.
|
||||
2. 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; unrelated `SdkError` false.
|
||||
3. 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](https://github.com/modelcontextprotocol/inspector/issues/1527) (**do**, after tests 1–4; product step 5 may land with them)
|
||||
|
||||
1. Land steps 1–5 (single PR preferred: “auth hardening CI gaps + era 401 unwrap”).
|
||||
2. 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.
|
||||
3. Close #1527 and move the board card to **Done**.
|
||||
4. 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](https://github.com/modelcontextprotocol/inspector/issues/1225))
|
||||
- Rewriting [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) with one scenario per SEP
|
||||
- EMA leg-3 SDK swap (blocked on SDK `resource`/`scope` — see [SDK consolidation](v2_auth_sdk_consolidation.md))
|
||||
|
||||
---
|
||||
|
||||
## Per-SEP detail (as-built)
|
||||
|
||||
### SEP-2468 — `iss` validation
|
||||
|
||||
- **SDK:** validates authorization-response `iss` against metadata when the host passes it into `auth()`.
|
||||
- **Inspector:** parses `iss` from the callback query string on web and TUI/CLI loopback; forwards through `OAuthManager.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:** `ClientSettingsForm` surfaces DCR `RegistrationRejectedError`; DCR marked deprecated-in-favor-of-CIMD (SEP-991).
|
||||
- **Tests:** `providers.test.ts` asserts `application_type: "native"`.
|
||||
|
||||
### SEP-2352 — issuer-bound credentials
|
||||
|
||||
- **Storage:** `byIssuer[issuer]` for client info / tokens / registration kind; `activeIssuer` for ctx-less bearer reads; re-attach `issuer` after Zod parse strips it; lazy legacy fallback until first stamped save.
|
||||
- **Provider:** `discoveryState` / `saveDiscoveryState`, `invalidateCredentials(scope)`.
|
||||
- **SDK:** stamps `ctx.issuer`, `discardIfIssuerMismatch` on read.
|
||||
- **Tests:** storage/provider unit + e2e `byIssuer` persistence; 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 in `refreshIdpOidcSession`.
|
||||
- **Mid-session:** silent refresh / EMA re-mint; never assume a refresh token exists.
|
||||
- **Tests:** refresh-after-401 e2e; standard-OAuth `offline_access` authorize 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](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)
|
||||
|
||||
```text
|
||||
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](https://github.com/modelcontextprotocol/inspector/issues/1225)).
|
||||
- Full 2026-07-28 era/`versionNegotiation` migration (era UI already exists; plan step 5 only fixes 401 detection under auto/modern — not a default-era flip).
|
||||
- `AuthRecoveryTransport` or 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)](https://modelcontextprotocol.io/specification/draft/basic/authorization)
|
||||
- [MCP authorization (2025-11-25 — current stable)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
|
||||
- [2026-07-28 RC announcement — Authorization Hardening](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/#authorization-hardening)
|
||||
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) — OAuth 2.0 Authorization Server Issuer Identification (`iss` parameter)
|
||||
- [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) — Bearer token `insufficient_scope`
|
||||
- TypeScript SDK tracking: [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) issues `#2197`–`#2201`, `#2198`, `#2256`; PRs `#2265`, `#2271`, `#2272`
|
||||
|
||||
## Relationship to mid-session auth
|
||||
|
||||
[Mid-session auth](v2_auth_mid_session.md) ships in the **near term**. Overlap:
|
||||
|
||||
| Concern | Mid-session spec | This doc |
|
||||
| ------- | ---------------- | -------- |
|
||||
| **SEP-2350** scope union on 403 step-up | `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })` for **web remote**, EMA, and **interactive** defer; union persisted on `completeOAuthFlow` success | **Direct streamable HTTP silent step-up:** v2 SDK transport ([#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265)). **Do not** duplicate with client-side fetch intercept or `AuthRecoveryTransport` on TUI/CLI |
|
||||
| **SEP-2207** refresh / `offline_access` | EMA legs 2–3 re-mint; standard OAuth silent refresh | Connect-time scope selection and DCR metadata |
|
||||
| **SEP-2468** `iss` validation | Applies to any interactive re-auth redirect (401 mid-session) | Connect-time callback wiring is the first place to land `iss` passthrough |
|
||||
| **SEP-2352** issuer-bound credentials | Re-register after AS migration may surface as mid-session failure | Storage and `invalidateCredentials` must be correct before mid-session recovery can succeed |
|
||||
| **TUI/CLI mid-session** | [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation): provider + UX; silent retry via v2 SDK | Provider v2 hooks (`invalidateCredentials`, issuer-keyed storage, `application_type`) |
|
||||
|
||||
Implement mid-session auth now. Land auth hardening on the v2 SDK upgrade path without blocking mid-session work — use compatible storage and callback shapes so both tracks merge cleanly.
|
||||
|
||||
## The six SEPs
|
||||
|
||||
| SEP | Requirement | Primary owner |
|
||||
| --- | ------------- | ------------- |
|
||||
| **[SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)** | Validate `iss` on authorization responses per RFC 9207; reject mix-up across authorization servers | Client (+ SDK reference impl) |
|
||||
| **[SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)** | Declare OIDC `application_type` during Dynamic Client Registration (`native` vs `web`) | Client (+ SDK DCR body) |
|
||||
| **[SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)** | Bind persisted DCR credentials to the AS `issuer`; re-register when resource migrates between ASes | Client storage (+ SDK AS-change detection) |
|
||||
| **[SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)** | Request refresh tokens from OIDC-style ASes (`offline_access` in scope when appropriate); do not assume refresh tokens are issued | Client (+ SDK `determineScope`) |
|
||||
| **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** | On 403 step-up, re-authorize with **union** of previously requested scopes and challenge scopes; on 401 re-login, **replace** scope | Client (+ SDK transport retry for direct transport) |
|
||||
| **[SEP-2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351)** | Stable RFC 8414 `.well-known` discovery suffix for authorization and resource metadata | Spec / discovery (SDK helpers) |
|
||||
|
||||
Servers emit per-operation scopes in 403 challenges (SEP-2350 server posture). Inspector consumes them; server-side scope-challenge middleware is out of scope unless needed for tests.
|
||||
|
||||
## SDK vs Inspector responsibilities
|
||||
|
||||
Inspector today uses **`@modelcontextprotocol/sdk` ^1.29.0** (v1 monolith). Auth hardening lands on **v2 `main`** (`@modelcontextprotocol/client`). Upgrade is part of this work.
|
||||
|
||||
```text
|
||||
Connect-time standard OAuth EMA Web remote backend
|
||||
─────────────────────────── ─── ──────────────────
|
||||
SDK auth() + BaseOAuthClientProvider Local wire (ema/) Frozen token stub
|
||||
OAuthStorage keyed by serverUrl IdP OIDC in idpOidc.ts No interactive OAuth on node
|
||||
completeOAuthFlow(code, iss?) v2 CrossAppAccess optional Mid-session → browser
|
||||
(evaluate vs ema/) fetch intercept (permanent)
|
||||
RemoteClientTransport (permanent)
|
||||
|
||||
TUI / CLI direct (not web remote):
|
||||
──────────────────────────────────
|
||||
StreamableHTTPClientTransport + live OAuthClientProvider in-process
|
||||
v2 SDK: silent 401/403 retry + unionScopes on streamable HTTP (#2265)
|
||||
Inspector: handleAuthChallenge for EMA + interactive defer; TUI/CLI UX only
|
||||
Do NOT: fetch intercept or AuthRecoveryTransport on client SDK transport
|
||||
```
|
||||
|
||||
### What the v2 SDK provides (after upgrade)
|
||||
|
||||
| SEP | SDK status (Jun 2026) | Inspector gets |
|
||||
| --- | --------------------- | -------------- |
|
||||
| SEP-2207 | Implemented on v2 `main` ([#2199](https://github.com/modelcontextprotocol/typescript-sdk/issues/2199)) | `offline_access` augmentation in authorize scope for standard OAuth via `auth()` |
|
||||
| SEP-2350 | Open PR [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265) | `unionScopes()` + 403 retry uses union on **streamable HTTP direct transport**; SSE unchanged (401 only) |
|
||||
| SEP-2352 | Open PR [#2271](https://github.com/modelcontextprotocol/typescript-sdk/pull/2271) | AS migration detection → `invalidateCredentials` → re-DCR |
|
||||
| SEP-2468 | Open PR [#2272](https://github.com/modelcontextprotocol/typescript-sdk/pull/2272) | `iss` validation in code-exchange path when host passes `iss` |
|
||||
| SEP-837 | Open [#2198](https://github.com/modelcontextprotocol/typescript-sdk/issues/2198) | `application_type` in DCR request body |
|
||||
| SEP-2351 | Tracking [#2256](https://github.com/modelcontextprotocol/typescript-sdk/issues/2256) | Discovery URL suffix in SDK metadata helpers |
|
||||
|
||||
### What Inspector must still implement
|
||||
|
||||
| SEP | Inspector work | Why not SDK-only |
|
||||
| --- | -------------- | ---------------- |
|
||||
| SEP-2468 | Parse `iss` from OAuth callback URL; extend `completeOAuthFlow(code, iss?)`; pass through to SDK `auth()` | Host owns the redirect handler — SDK cannot read the callback URL |
|
||||
| SEP-837 | Ensure web vs TUI/CLI set correct client type (`web` / `native`) in provider metadata or environment | SDK may infer from redirect URI; Inspector must not mis-declare cross-environment |
|
||||
| SEP-2352 | Key `OAuthStorage` credentials by AS `issuer` (not only `serverUrl`); implement `invalidateCredentials` on `BaseOAuthClientProvider`; migrate shared `oauth.json` | Storage is Inspector-owned; SDK calls provider hooks |
|
||||
| SEP-2207 | EMA leg 1 already requests `openid offline_access` (`IDP_OIDC_SCOPES`); verify standard OAuth path after upgrade | EMA bypasses SDK authorize for leg 1 |
|
||||
| SEP-2350 | **Web remote:** mid-session union in `handleAuthChallenge()` (bypasses SDK — proxy architecture). **TUI/CLI direct silent step-up:** v2 SDK transport after #2265. **Interactive defer** (step-up modal / Auth tab confirm): Inspector `setSuppressAuthorizationNavigation` + `mcpAuth({ forceReauthorization })` | See [Mid-session auth § Core API](v2_auth_mid_session.md#core-api--handleauthchallenge) and [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation) |
|
||||
| SEP-2351 | Verify discovery after upgrade; no duplicate discovery logic in Inspector | Uses SDK `discoverOAuthProtectedResourceMetadata` today |
|
||||
|
||||
**Do not duplicate** SDK `auth.ts` logic (RFC 9207 decision table, AS migration edge cases, scope union helpers). Upgrade and wire.
|
||||
|
||||
## Implementation strategy
|
||||
|
||||
### Principles
|
||||
|
||||
1. **Delegate** connect-time standard OAuth to the v2 SDK `auth()` flow.
|
||||
2. **Wire** callback parameters, storage, and client-type metadata in Inspector.
|
||||
3. **Extend** EMA and mid-session paths locally where the SDK has no EMA/XAA API.
|
||||
4. **Upgrade** from `@modelcontextprotocol/sdk` v1 to v2 packages as a gated step — not a silent dependency bump.
|
||||
5. **Do not unify transport layers** between web remote and TUI/CLI — web keeps `RemoteClientTransport` + node fetch intercept; TUI/CLI rely on v2 SDK transport for silent direct retry.
|
||||
|
||||
### Transport ownership (v2 upgrade)
|
||||
|
||||
| Path | Silent 401/403 + RPC retry | Interactive OAuth | Inspector integration |
|
||||
| ---- | -------------------------- | ----------------- | --------------------- |
|
||||
| **Web remote** | `RemoteClientTransport.postSend()` + `pushAuthState` (unchanged) | Browser redirect + resume snapshot | Node fetch intercept only (frozen stub) |
|
||||
| **TUI/CLI direct (streamable HTTP)** | **v2 SDK transport** (`token()` + retry; #2265 union on 403) | Provider `redirectToAuthorization` + host callback | `handleAuthChallenge()` for EMA + defer-navigate; catch `UnauthorizedError` / `SdkHttpError` |
|
||||
| **TUI/CLI direct (SSE)** | v2 SDK 401 retry only | Same as streamable | No 403 step-up on SSE |
|
||||
|
||||
**v2 SDK hooks (direct):** `AuthProvider` with `token()` + optional `onUnauthorized()`; full `OAuthClientProvider` adapted internally. Inspector extends `BaseOAuthClientProvider` toward the v2 contract — **not** a parallel fetch intercept on the client transport.
|
||||
|
||||
**Delete on upgrade:** `core/auth/mcpAuth.ts` body → re-export SDK `auth`; delete `core/auth/scopes.ts` → import `unionScopes` from SDK. **Keep:** `handleAuthChallenge()`, web `RemoteClientTransport`, node `createAuthChallengeInterceptFetch`.
|
||||
|
||||
### Order of work
|
||||
|
||||
| Step | When | Work |
|
||||
| ---- | ---- | ---- |
|
||||
| 1 | **Now (parallel with [TUI/CLI mid-session](v2_auth_mid_session.md#tui-and-cli-implementation))** | Compatible prep: callback `iss`; storage schema reserves `authorizationServerIssuer`; provider v2-shaped hooks; direct mid-session e2e baseline; **no** client-side fetch intercept / `AuthRecoveryTransport` |
|
||||
| 2 | **When v2 SDK auth PRs merge** | Upgrade to `@modelcontextprotocol/client`; run existing OAuth integration tests |
|
||||
| 3 | **Immediately after upgrade** | SEP-2468 callback passthrough; SEP-2352 storage + `invalidateCredentials`; SEP-837 `application_type` per environment; wire provider `onUnauthorized` → `handleAuthChallenge` where interactive defer needed |
|
||||
| 4 | **TUI/CLI Phase C** | Interactive mid-session UX (Auth tab / callback server) — can overlap step 3 |
|
||||
| 5 | **Verify** | Smoke scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md); direct mid-session e2e green on v2 |
|
||||
|
||||
Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, with **SEP-837** in the same auth-release series.
|
||||
|
||||
### Per-SEP Inspector mapping
|
||||
|
||||
#### SEP-2468 — `iss` validation (RFC 9207)
|
||||
|
||||
- **Web:** `App.tsx` OAuth callback — read `iss` from query string alongside `code`; pass to `InspectorClient.completeOAuthFlow(code, iss?)`.
|
||||
- **TUI / CLI:** Node callback server — same passthrough.
|
||||
- **Core:** `OAuthManager.completeOAuthFlow` forwards `iss` to SDK `auth()`.
|
||||
- **EMA:** Leg 1 OIDC callback is separate (`completeEmaIdpAuthorizationAndMint`); evaluate whether IdP responses require `iss` validation on the OIDC path (may reuse SDK OIDC helpers after upgrade).
|
||||
|
||||
#### SEP-837 — `application_type` in DCR
|
||||
|
||||
- **Web (`BrowserOAuthClientProvider`):** `application_type: "web"` (https redirect).
|
||||
- **TUI / CLI:** `application_type: "native"` (localhost / custom scheme redirect).
|
||||
- **Pre-registered clients:** N/A — no DCR.
|
||||
- **CIMD:** Portable client IDs — no `application_type` on DCR.
|
||||
|
||||
#### SEP-2352 — credentials bound to AS issuer
|
||||
|
||||
- Extend `ServerOAuthState` / `oauth.json` with `authorizationServerIssuer` (or key storage by issuer + resource).
|
||||
- Implement `OAuthClientProvider.invalidateCredentials(scope)` on `BaseOAuthClientProvider` — clear client info and/or tokens per SDK request.
|
||||
- On AS migration (PRM `authorization_servers` change), SDK triggers invalidation; Inspector storage must honor it and allow re-DCR.
|
||||
- **CIMD carve-out:** HTTPS `client_id` URLs remain portable across AS changes; tokens still invalidate per SDK behavior.
|
||||
|
||||
#### SEP-2207 — OIDC refresh token guidance
|
||||
|
||||
- **Standard OAuth:** Inherited from v2 SDK `determineScope()` after upgrade.
|
||||
- **EMA leg 1:** Already requests `openid offline_access`; IdP refresh in `refreshIdpOidcSession`.
|
||||
- **Mid-session:** Silent refresh and EMA re-mint per [Mid-session auth](v2_auth_mid_session.md); do not assume refresh tokens exist.
|
||||
|
||||
#### SEP-2350 — step-up scope accumulation
|
||||
|
||||
- **Web remote (mid-session):** `handleAuthChallenge()` union + `pushAuthState` — SDK transport not involved (proxy).
|
||||
- **TUI/CLI direct (silent, streamable HTTP):** v2 SDK transport 403 retry with `unionScopes()` after [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265).
|
||||
- **Interactive step-up (all clients):** `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })`; `setSuppressAuthorizationNavigation` until user confirms (web modal / TUI Auth tab); `saveScope` on `completeOAuthFlow` success.
|
||||
- **EMA step-up:** silent legs 2–3 when IdP session valid — Inspector `handleAuthChallenge()`, not SDK transport alone.
|
||||
- **401 re-login:** Replace scope set (not union) — all clients.
|
||||
- **SSE:** 401 only; no 403 step-up in v1 or v2 SDK.
|
||||
|
||||
#### SEP-2351 — discovery suffix
|
||||
|
||||
- Rely on v2 SDK discovery helpers used by `auth()`, CIMD (`core/auth/cimd.ts`), and EMA (`core/auth/ema/resourceContext.ts`).
|
||||
- No Inspector-specific discovery URL construction unless regression found in smoke tests.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Reimplementing MCP authorization-server or resource-server wire formats.
|
||||
- v1 / v1.5 backport.
|
||||
- **Client credentials grant** ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)) — separate track.
|
||||
- Full MCP **2026-07-28 stateless transport** migration (separate from auth hardening).
|
||||
- Server-side scope-challenge middleware in Inspector test servers beyond what [Mid-session auth](v2_auth_mid_session.md) requires for 403 fixtures.
|
||||
- **`AuthRecoveryTransport`** or client-side fetch intercept on TUI/CLI SDK transport (defer silent retry to v2 SDK; web node intercept stays).
|
||||
- Unifying web remote and TUI/CLI at the transport wrapper layer.
|
||||
|
||||
## Testing
|
||||
|
||||
| SEP | Test |
|
||||
| --- | ---- |
|
||||
| SEP-2468 | Callback with matching `iss` succeeds; mismatched `iss` rejected; AS advertising support without `iss` rejected |
|
||||
| SEP-837 | DCR succeeds for web (https redirect) and TUI/CLI (localhost redirect) against OIDC AS requiring `application_type` |
|
||||
| SEP-2352 | Simulated AS migration in PRM → credentials invalidated → re-DCR → connect succeeds |
|
||||
| SEP-2207 | Authorize request includes `offline_access` when AS advertises it; connect succeeds without refresh token when AS omits one |
|
||||
| SEP-2350 | Connect-time and mid-session 403 step-up use union scopes; 401 re-login replaces scopes |
|
||||
| SEP-2351 | Discovery resolves against draft suffix paths |
|
||||
|
||||
Document manual scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) after implementation.
|
||||
|
||||
## File touch list (expected)
|
||||
|
||||
| Area | Files |
|
||||
| ---- | ----- |
|
||||
| Upgrade | Root and client `package.json`; import paths v1 → `@modelcontextprotocol/client` |
|
||||
| Callback | `clients/web/src/App.tsx`, `core/auth/node/oauth-callback-server.ts`, `core/mcp/oauthManager.ts`, `core/mcp/inspectorClient.ts` |
|
||||
| Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` — v2 hooks: `invalidateCredentials`, issuer-keyed storage, `application_type` |
|
||||
| Storage | `core/auth/store.ts`, `core/auth/oauth-storage.ts`, `core/auth/oauth-persist.ts`, `core/auth/remote/storage-remote.ts`, `clients/web/src/lib/remoteOAuthStorage.ts` |
|
||||
| Web remote (unchanged on v2) | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/node/authChallengeFetch.ts`, `core/mcp/remote/node/server.ts` |
|
||||
| TUI/CLI | [Mid-session § TUI and CLI](v2_auth_mid_session.md#tui-and-cli-implementation): `clients/tui/src/App.tsx`; `clients/cli/src/cliOAuth.ts`; provider wiring in `core/mcp/inspectorClient.ts` |
|
||||
| Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) |
|
||||
| Delete on upgrade | `core/auth/mcpAuth.ts` (re-export SDK), `core/auth/scopes.ts` (use SDK `unionScopes`) |
|
||||
| Tests | `inspectorClient-oauth*.test.ts`, `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (planned) |
|
||||
| Smoke doc | `specification/v2_auth_smoke_testing.md` |
|
||||
- [2026-07-28 RC — Authorization Hardening](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/#authorization-hardening)
|
||||
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) — Authorization Server Issuer Identification
|
||||
- Tracking issue: [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527)
|
||||
|
||||
## Related specs
|
||||
|
||||
| Doc | Relationship |
|
||||
| --- | ------------ |
|
||||
| [v2_auth_mid_session.md](v2_auth_mid_session.md) | Mid-session auth (near term); owns runtime SEP-2350 behavior and most recovery UX |
|
||||
| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 1–3; SEP-2207 leg 1 scopes; future v2 SDK Layer-2 helpers |
|
||||
| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual verification ladder |
|
||||
| [v2_scope.md](v2_scope.md) | OAuth handling scope item |
|
||||
| [v2_storage.md](v2_storage.md) | Shared `oauth.json` — issuer-keyed credentials affect storage schema |
|
||||
| Doc | Relationship |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------- |
|
||||
| [v2_auth_mid_session.md](v2_auth_mid_session.md) | SEP-2350 runtime + recovery UX (done) |
|
||||
| [v2_auth_sdk_consolidation.md](v2_auth_sdk_consolidation.md) | What auth code sits on the v2 SDK vs Inspector |
|
||||
| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs; SEP-2207 IdP scopes |
|
||||
| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Hosted-IdP manual verification (complementary to CI) |
|
||||
| [v2_storage.md](v2_storage.md) | Shared `oauth.json` / issuer-keyed credentials |
|
||||
|
|
|
|||
Loading…
Reference in a new issue