Repository navigation
feat(auth): opt-in scope on OAuth token refresh via scopeForRefresh (Entra AADSTS90009) - #2809
Merged
Merged
Conversation
…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>
There was a problem hiding this comment.
🟡 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
…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>
Member
Author
|
Copilot round 1: one finding, fixed in 0d620e3.
Also retook the screenshots with only the OAuth Settings section expanded. |
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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 answersAADSTS90009. The Inspector refreshes through the SDK'sauth(), which sends noscopeon a refresh, so against such a server every refresh fails and the user has to sign in again. SDK 2.4.0 added an optionalOAuthClientProvider.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 returnsundefined, 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:
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.scopewhen it equals the requested one. In that case the request scope is the grant. It gets the same Hardcodedprompt=consenton every OAuth authorize request breaks Entra ID tenants requiring admin consent (AADSTS90094) #2068offline_accessfilter (scopeForDeclinedRefreshGrant) that was applied when that request was sent.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:
EmaTransportOAuthProvidernow forwardsscopeForRefreshto the inner provider. Without that, an EMA connection would refresh withoutscopeno matter what the setting says.Plumbing. The setting follows the same path
requestRefreshToken(#2068) does, with the default inverted, so onlytrueis written:mcp.jsonstores it asoauth.scopeOnRefresh. Settings carry it asoauthScopeOnRefresh(core/mcp/serverList.tshandles both directions)./api/serversroute validates it on both the stored-config shape and the settings payload.OAuthManagerto the provider, and throughInspectorClient.setOAuthConfig.core/client/runner.ts) and the web connect path (useConnectionLifecycle) both forward it.AADSTS90009and explains why the default is off.docs/mcp-server-configuration.mddocuments the field.Tests
inspectorClient-oauth-e2e.test.ts, SSE and Streamable HTTP): runs a real authorize, refresh and reconnect against the test AS, and inspects the actualgrant_type=refresh_tokenrequest body. With the setting off there is noscope; with it on,scopecontainsmcp. I checked the test catches a regression: withscopeForRefreshforced to returnundefined, both "on" cases fail.prompt=consenton every OAuth authorize request breaks Entra ID tenants requiring admin consent (AADSTS90094) #2068 filter on the fallback.scopeForRefresh.OAuthManager: a bridge test that pins the config → provider line.useConnectionLifecycle: forwarding.Not done
OAuthErrorfrom 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 namesAADSTS90009, so a user who searches that error lands on the setting.Screenshots
Server Settings → OAuth Settings. These were captured from the
Groups/ServerSettingsForm→WithOAuthstory at 1280px, full page, with only the OAuth Settings section expanded.Default (off):
Opted in:
🤖 Generated with Claude Code