Skip to content

feat(auth): opt-in scope on OAuth token refresh via scopeForRefresh (Entra AADSTS90009) - #2809

Merged
cliffhall merged 2 commits into
v2/mainfrom
v2/feat/2805-scope-on-refresh
Oct 25, 2026
Merged

cliffhall merged 2 commits into
v2/mainfrom
v2/feat/2805-scope-on-refresh

Conversation

@cliffhall

@cliffhall cliffhall commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Closes #2805

Problem

Some authorization servers reject a refresh-token request that carries no scope. Microsoft Entra ID does this when the client and the resource are the same app registration: it answers AADSTS90009. The Inspector refreshes through the SDK's auth(), which sends no scope on a refresh, so against such a server every refresh fails and the user has to sign in again. SDK 2.4.0 added an optional OAuthClientProvider.scopeForRefresh(tokens) for this.

What changed

BaseOAuthClientProvider.scopeForRefresh (core/auth/providers.ts), behind a new per-server opt-in, scopeOnRefresh. It is off by default. While it is off the method returns undefined, so the SDK's refresh request is exactly what it was before.

When it is on, the SDK requires that the returned scope not exceed the grant. So the sources are tried in order of how directly they state the grant:

  1. tokens.scope, the granted scope saved with the tokens. This is the authorization server's own statement of the grant, and it already reflects a step-up.
  2. The stored authorization-request scope. RFC 6749 §5.1 lets a token response omit scope when it equals the requested one. In that case the request scope is the grant. It gets the same Hardcoded prompt=consent on every OAuth authorize request breaks Entra ID tenants requiring admin consent (AADSTS90094) #2068 offline_access filter (scopeForDeclinedRefreshGrant) that was applied when that request was sent.
  3. The configured scope, used when storage recorded no request scope.

There is no conflict with step-up: a step-up that needs a wider grant makes the SDK skip refresh (forceReauthorization), so this method never asks a refresh for more than was granted.

EMA: EmaTransportOAuthProvider now forwards scopeForRefresh to the inner provider. Without that, an EMA connection would refresh without scope no matter what the setting says.

Plumbing. The setting follows the same path requestRefreshToken (#2068) does, with the default inverted, so only true is written:

  • mcp.json stores it as oauth.scopeOnRefresh. Settings carry it as oauthScopeOnRefresh (core/mcp/serverList.ts handles both directions).
  • The remote /api/servers route validates it on both the stored-config shape and the settings payload.
  • It is passed through OAuthManager to the provider, and through InspectorClient.setOAuthConfig.
  • The CLI/TUI runner (core/client/runner.ts) and the web connect path (useConnectionLifecycle) both forward it.
  • Server Settings → Authorization has a new Send scope on token refresh checkbox. Its description names AADSTS90009 and explains why the default is off.
  • docs/mcp-server-configuration.md documents the field.

Tests

  • e2e (inspectorClient-oauth-e2e.test.ts, SSE and Streamable HTTP): runs a real authorize, refresh and reconnect against the test AS, and inspects the actual grant_type=refresh_token request body. With the setting off there is no scope; with it on, scope contains mcp. I checked the test catches a regression: with scopeForRefresh forced to return undefined, both "on" cases fail.
  • Provider unit tests: default and explicit off, the granted scope, each fallback, the no-scope-anywhere case, and the Hardcoded prompt=consent on every OAuth authorize request breaks Entra ID tenants requiring admin consent (AADSTS90094) #2068 filter on the fallback.
  • EMA: the wrapper delegates scopeForRefresh.
  • OAuthManager: a bridge test that pins the config → provider line.
  • serverList: lifting the setting, writing only the opt-in, and the round trip.
  • Remote route: rejects a non-boolean in both shapes; a save persists the opt-in and writes nothing when off.
  • Runner and useConnectionLifecycle: forwarding.
  • Form and modal: the checkbox's default state, its checked state, the toggle, and the mapping back to unset.

Not done

  • Pointing the user at the setting when a refresh fails with a missing-scope error. The issue marks this optional ("consider"). The SDK throws a generic OAuthError from the refresh, and nothing marks it as a refresh failure, so a reliable hint would mean classifying error text per authorization server. Instead, the checkbox description names AADSTS90009, so a user who searches that error lands on the setting.
  • Changing the default refresh request, which the issue rules out of scope.

Screenshots

Server Settings → OAuth Settings. These were captured from the Groups/ServerSettingsForm → WithOAuth story at 1280px, full page, with only the OAuth Settings section expanded.

Default (off):

oauth-settings-scope-on-refresh-off

Opted in:

oauth-settings-scope-on-refresh-on

🤖 Generated with Claude Code

…2805)

Some authorization servers reject a refresh-token request with no `scope`;
Microsoft Entra ID answers AADSTS90009 when the client and the resource are
the same app registration, so every refresh fails and the user must sign in
again. SDK 2.4.0 adds an optional `OAuthClientProvider.scopeForRefresh`.

Implement it on BaseOAuthClientProvider behind a new per-server setting,
"Send scope on token refresh" (`oauth.scopeOnRefresh` in mcp.json,
`oauthScopeOnRefresh` in settings), off by default so the refresh request is
unchanged unless a user opts in. When on, the scope sent is the granted
scope saved with the tokens, falling back to the authorization request's
stored scope (with the #2068 offline_access filter) and then the configured
scope. The EMA transport provider forwards the method.

Plumbed through the config round-trip, the remote settings route, the
OAuth manager, the CLI/TUI runner and the web connect path, with an e2e test
that inspects the real refresh request body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Oct 25, 2026
@cliffhall
cliffhall requested a balanced review from Copilot October 25, 2026 05:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The refresh fallback can add a scope outside the recorded grant after configuration changes, causing valid refresh tokens to fail.

1 open issue
What changed in this PR

Adds per-server, opt-in OAuth refresh scopes to address Entra ID AADSTS90009, while preserving the default refresh request without scope.

Changes:

  • Implements scope selection in the shared OAuth provider and delegation through EMA.
  • Threads the setting through configuration, connection setup, and the web settings UI.
  • Adds documentation and tests covering persistence, forwarding, UI behavior, and refresh requests.
File Description
docs/​mcp-server-configuration.md Documents the opt-in and default behavior.
core/​mcp/​types.ts Adds refresh-scope configuration types.
core/​mcp/​serverList.ts Maps and persists the opt-in.
core/​mcp/​remote/​node/​server.ts Validates and normalizes the setting.
core/​mcp/​oauthManager.ts Passes the setting to providers.
core/​mcp/​inspectorClient.ts Extends the OAuth configuration interface.
core/​client/​runner.ts Forwards the setting for runner clients.
core/​auth/​providers.ts Implements refresh-scope selection.
core/​auth/​ema/​transportProvider.ts Delegates refresh-scope selection.
clients/​web/​src/​test/​integration/​mcp/​remote/​server-extra-coverage.test.ts Tests validation and persistence.
clients/​web/​src/​test/​integration/​mcp/​inspectorClient-oauth-e2e.test.ts Checks refresh request bodies over both transports.
clients/​web/​src/​test/​core/​mcp/​serverList.test.ts Tests configuration round trips.
clients/​web/​src/​test/​core/​mcp/​oauthManager.test.ts Tests provider configuration forwarding.
clients/​web/​src/​test/​core/​client/​runner.test.ts Tests runner option forwarding.
clients/​web/​src/​test/​core/​auth/​providers.test.ts Tests scope selection and fallbacks.
clients/​web/​src/​test/​core/​auth/​ema/​transportProvider.test.ts Tests EMA delegation.
clients/​web/​src/​hooks/​useConnectionLifecycle.ts Forwards the web connection option.
clients/​web/​src/​hooks/​useConnectionLifecycle.test.tsx Adds a standalone opt-in settings case.
clients/​web/​src/​components/​groups/​ServerSettingsModal/​ServerSettingsModal.tsx Maps checkbox values to persisted settings.
clients/​web/​src/​components/​groups/​ServerSettingsModal/​ServerSettingsModal.test.tsx Tests enabling and clearing the opt-in.
clients/​web/​src/​components/​groups/​ServerSettingsForm/​ServerSettingsForm.tsx Adds the refresh-scope checkbox and guidance.
clients/​web/​src/​components/​groups/​ServerSettingsForm/​ServerSettingsForm.test.tsx Tests checkbox defaults and interaction.

Review effort: Balanced

Comment thread core/auth/providers.ts Outdated
…2805)

The #2068 offline_access helper re-adds a configured offline_access, which
could ask a refresh for a scope outside the grant after a configuration
change and fail it with invalid_scope. Apply it only to remove tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1: one finding, fixed in 0d620e3.

Also retook the screenshots with only the OAuth Settings section expanded.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

Static review found no unresolved blocking issues, and the prior scope-expansion defect has regression coverage; tests were not executed during this review.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review loop closed: round 2 was clean (no inline comments, no open issues, no suppressed findings; the round-1 finding is marked resolved). Stopping on the first clean round.

@cliffhall
cliffhall merged commit 4bb5ee7 into v2/main Oct 25, 2026
11 checks passed
@cliffhall
cliffhall deleted the v2/feat/2805-scope-on-refresh branch October 25, 2026 05:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opt-in scope on OAuth token refresh via the SDK's scopeForRefresh (Entra ID AADSTS90009)

2 participants