Skip to content

Add a scope selector for OAuth authorization-code clients - #579

Open
atesgoral wants to merge 2 commits into
modelcontextprotocol:mainfrom
atesgoral:ag/oauth-explicit-empty-scope
Open

atesgoral wants to merge 2 commits into
modelcontextprotocol:mainfrom
atesgoral:ag/oauth-explicit-empty-scope

Conversation

@atesgoral

@atesgoral atesgoral commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Why

The MCP 2026-07-28 scope-selection strategy recommends the challenge's scope, then PRM scopes_supported (intended as a minimal basic set). The Ruby SDK follows that default. Some hosted clients need a narrower per-user policy, including a first authorization request with no PRM defaults; authorization_request_validator can refuse but cannot change the selected scopes.

Fixes #578.

What changes

Add optional Provider.new(scope_selector:) for authorization-code clients. It receives a read-only array of PRM scopes_supported when the SDK selects those defaults instead of a challenged scope, and returns an array containing a subset; [] requests none of those defaults. nil results and custom additions are refused. Challenged scopes and the step-up union bypass it, and provider fallback is unchanged. It runs before offline_access augmentation, authorization-request validation and client registration. The existing offline_access policy and endpoint-query handling are unchanged; unsupported offline_access remains stripped. Returning [] does not guarantee an omitted scope parameter or prevent the AS from applying defaults (RFC 6749 §3.3).

With no selector, the SDK's spec-aligned default and other OAuth grants are unchanged. This follows the application-policy hook pattern in the C# SDK and Go SDK.

Scope selection, illustrated

These examples assume no challenged scope, a Protected Resource Metadata (PRM) document advertising read write admin, and an authorization server (AS) supporting offline_access with a client declaring the refresh_token grant. The authorization-request validator shown here is optional.

Default versus application policy

OAuth PRM default selection: unchanged default, read subset or empty subset before offline-access policy, with challenged scopes bypassing the selector

The hook changes what the client requests, not what the AS grants. Returning [] requests no PRM defaults; in this example, the existing refresh-token policy still adds offline_access. It does not prevent AS defaults.

Placement in a hosted OAuth flow

Hosted OAuth sequence: empty PRM selection followed by grant-based offline_access, validation, registration and browser authorization

This uses the existing two-leg OAuth flow from PR 573. The scope selector is the addition here; callback persistence and Flow#finish! are existing behavior. The host must inspect the actual granted scopes. The selector does not change endpoint-query handling: a nonempty effective scope replaces a prefilled scope, while a prefilled scope survives when the flow has none.

Validation

  • All 11 OAuth test files pass locally: 433 tests, 1,401 assertions.
  • The full client suite, including OAuth, passes locally: 736 tests, 2,323 assertions.
  • Ruby syntax checks and git diff --check pass.
  • Direct Ruby RuboCop passes for the four changed Ruby files; no dependency or native-extension rebuild.

@atesgoral atesgoral changed the title Let OAuth clients select only explicit scopes Add a scope selector for OAuth authorization-code clients Sep 28, 2026
@atesgoral
atesgoral force-pushed the ag/oauth-explicit-empty-scope branch from 11d7b49 to 7194f1e Compare October 1, 2026 19:39
@atesgoral
atesgoral marked this pull request as ready for review October 1, 2026 19:45
@atesgoral
atesgoral requested a review from koic October 1, 2026 19:48
Comment thread lib/mcp/client/oauth/flow.rb Outdated

effective_scope = resolve_scope(scope: scope, prm: prm)
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
effective_scope = select_scope(effective_scope, as_metadata: as_metadata)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One design question before the detailed review.

scope_selector receives the final list, so it cannot tell a challenged scope from a default one. On a 403 step-up, an allowlist selector that leaves out the challenged scope makes the flow re-authorize for what is already granted, and the retry fails with 403 again. Without a callback_handler, each such request ends in AuthorizationPendingError, so the user is redirected in a loop.

The spec treats the two sources differently: challenged scopes are authoritative for the operation, while scopes_supported is a default. I would like the selector to cover only that default:

  • Called only when the challenge names no scope, with PRM scopes_supported. It returns a subset, and [] requests none of them.
  • Challenged scopes skip it. authorization_request_validator already accepts or refuses them.
  • offline_access stays controlled by grant_types. The step-up union and the endpoint URL's query are left as they are.

That keeps each hook to one job, and a narrow contract can be widened after release while a wide one cannot be narrowed.

It does not let a host narrow the scopes of a 401 challenge, or drop offline_access through the hook. If your use case needs either of those, could you describe it? If I'm misunderstanding anything here, please feel free to correct me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. The selector now narrows only unchallenged PRM defaults, with subset-only results. Challenged scopes and the step-up union bypass it; the validator accepts or refuses them. offline_access retains the existing grant_types policy. I removed the selector-specific endpoint cleanup, preserving the existing endpoint-query behavior.

[] means no PRM scopes, not necessarily no scope parameter. The docs and examples now describe that narrower contract.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let OAuth clients filter requested scopes

2 participants