Skip to content

Commit c5a7ce9

Browse files
committed
fix: limit OAuth scope selection to PRM defaults
1 parent 7194f1e commit c5a7ce9

5 files changed

Lines changed: 158 additions & 104 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
4747
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
4848
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
4949
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
50-
- By default, request `offline_access` when the client declares the `refresh_token` grant and the authorization server advertises it in
51-
`scopes_supported` (SEP-2207). This can enable refresh tokens. The optional `scope_selector` below can remove it, while a selector
52-
cannot reintroduce `offline_access` when the authorization server does not advertise it.
50+
- Request `offline_access` when the client declares the `refresh_token` grant and the authorization server advertises it in
51+
`scopes_supported` (SEP-2207). The PRM scope selector below runs before this augmentation and cannot disable it.
52+
Unsupported `offline_access` is stripped from every scope source.
5353

5454
```ruby
5555
require "mcp"
@@ -100,14 +100,19 @@ Optional keyword arguments:
100100
- `pending_authorization_max_age`: Integer seconds a pending authorization stays redeemable, counted from the moment `run!` saves it, when `callback_handler`
101101
is omitted. Defaults to 600.
102102
- `scope`: Space-separated fallback scopes when neither a challenge nor PRM advertises scopes.
103-
- `scope_selector`: Optional callable invoked after the SDK chooses scopes from the challenge, PRM, or `scope` fallback and augments
104-
`offline_access`, but before request validation and client registration. It receives a read-only array of candidate scope tokens;
105-
return an array of valid OAuth scope tokens to replace them, or `nil` / `[]` to omit the authorization URL's `scope` parameter.
106-
The default (`nil`) keeps the MCP scope-selection strategy unchanged. A selector can narrow or add custom scopes, but cannot
107-
reintroduce unsupported `offline_access`. Filtering a challenged scope may leave the current operation unauthorized.
108-
Return `[]` to omit the client's `scope` parameter on the first request, even if PRM advertises scopes. The AS can still apply
109-
default scopes or reject the request ([RFC 6749 §3.3](https://www.rfc-editor.org/rfc/rfc6749#section-3.3)); check the granted
110-
scope before treating the connection as unscoped, and allow later challenged scopes when needed.
103+
- `scope_selector`: Optional callable for narrowing Protected Resource Metadata (PRM) defaults in the authorization-code flow.
104+
It is invoked only when no nonempty challenged scope was supplied and PRM supplies the default `scopes_supported` list,
105+
before `offline_access` augmentation, request validation, and client registration. It receives a read-only array of PRM tokens
106+
and must return an array containing a subset; custom scopes and `nil` results raise `ArgumentError`. Return `[]` to request
107+
none of those PRM defaults. Challenged scopes, including the step-up union, and provider fallback bypass the selector;
108+
use `authorization_request_validator` to accept or refuse challenged scopes. With no selector, default behavior is unchanged.
109+
This hook does not control `offline_access` augmentation or alter authorization-endpoint query parameters. Returning `[]`
110+
does not guarantee an omitted `scope` parameter: refresh policy can add `offline_access`, and a prefilled endpoint scope
111+
survives when the flow has no scope of its own. The AS can also apply defaults or reject the request
112+
([RFC 6749 §3.3](https://www.rfc-editor.org/rfc/rfc6749#section-3.3)); inspect the granted scopes.
113+
For example, with PRM defaults `mcp:read mcp:write`, `scope_selector: ->(scopes) { scopes & ["mcp:read"] }` narrows the
114+
default request to `mcp:read`; `scope_selector: ->(_scopes) { [] }` requests no PRM defaults. If the AS supports
115+
`offline_access` and the client declares `refresh_token`, either request still includes `offline_access` afterward.
111116
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
112117
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
113118
- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard.

‎lib/mcp/client/oauth/flow.rb‎

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,8 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
267267

268268
ensure_pkce_supported!(as_metadata)
269269

270-
effective_scope = resolve_scope(scope: scope, prm: prm)
270+
effective_scope = resolve_scope(scope: scope, prm: prm, select_prm_scope: true)
271271
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
272-
effective_scope = select_scope(effective_scope, as_metadata: as_metadata)
273272

274273
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
275274
# the embedding application has just rejected.
@@ -861,9 +860,8 @@ def ensure_same_origin!(url, label:, server_url:)
861860
# places on MCP servers rather than on clients.
862861
# A host that knows which providers its user deals with can apply that knowledge here.
863862
#
864-
# By default, challenged scopes pass through unchanged. An explicit scope selector may narrow
865-
# them, accepting that the current operation could remain unauthorized. The validator sees the
866-
# final selection and can still refuse the authorization before client registration.
863+
# Challenged scopes are authoritative for the operation and bypass the PRM scope selector.
864+
# The validator can accept or refuse them, not quietly request fewer scopes.
867865
#
868866
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
869867
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
@@ -1307,13 +1305,17 @@ def authorization_response_error(error, description)
13071305
end
13081306

13091307
# MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback.
1310-
# A provider's optional selector can adjust the result after `offline_access` augmentation.
1311-
def resolve_scope(scope:, prm:)
1308+
# Authorization-code clients may narrow only the PRM default, before `offline_access` augmentation.
1309+
def resolve_scope(scope:, prm:, select_prm_scope: false)
13121310
return scope if scope && !scope.empty?
13131311

13141312
# `prm` is nil on the legacy path, where nothing advertises scopes.
13151313
supported = prm && prm["scopes_supported"]
1316-
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
1314+
if supported.is_a?(Array) && !supported.empty?
1315+
return select_prm_scopes(supported) if select_prm_scope
1316+
1317+
return supported.join(" ")
1318+
end
13171319

13181320
return @provider.scope if @provider.scope && !@provider.scope.empty?
13191321

@@ -1355,23 +1357,20 @@ def server_supports_offline_access?(as_metadata)
13551357
supported.is_a?(Array) && supported.include?("offline_access")
13561358
end
13571359

1358-
# Applies application policy after standard scope selection but before validation or registration.
1359-
def select_scope(scope, as_metadata:)
1360+
# Selects a subset of the PRM default without changing challenges, provider fallback, or refresh policy.
1361+
def select_prm_scopes(scopes)
13601362
selector = @provider.scope_selector if @provider.respond_to?(:scope_selector)
1361-
return scope unless selector
1363+
return scopes.join(" ") unless selector
13621364

1363-
selected = selector.call(scope.to_s.split.freeze)
1364-
valid = selected.is_a?(Array) &&
1365-
selected.all? { |token| token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) }
1366-
unless selected.nil? || valid
1367-
raise ArgumentError, "scope_selector must return nil or an Array of valid OAuth scope tokens."
1365+
candidates = scopes.map { |token| token.dup.freeze }.freeze
1366+
selected = selector.call(candidates)
1367+
valid = selected.is_a?(Array) && selected.all? do |token|
1368+
token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) && candidates.include?(token)
13681369
end
1369-
return if selected.nil?
1370-
1371-
# Custom scopes are allowed, but unsupported offline_access remains refused.
1372-
unless server_supports_offline_access?(as_metadata)
1373-
selected = selected.reject { |token| token == "offline_access" }
1370+
unless valid
1371+
raise ArgumentError, "scope_selector must return an Array containing only scopes from PRM scopes_supported."
13741372
end
1373+
13751374
selected.empty? ? nil : selected.join(" ")
13761375
end
13771376

@@ -1437,9 +1436,8 @@ def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_chall
14371436
# RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
14381437
# its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
14391438
# for the client's `client_id`, `redirect_uri`, `code_challenge`, or `resource`.
1440-
# Other endpoint parameters are kept, as the TypeScript SDK's `searchParams.set` keeps them.
1441-
# Without a selector, an endpoint `scope` survives when the flow has none: the AS may set a default there.
1442-
# A configured selector owns `scope` even when omitting it, so the endpoint query cannot override policy.
1439+
# Other parameters in the URL are kept, as the TypeScript SDK's `searchParams.set` keeps them; that includes
1440+
# a `scope` when the flow has none, since an authorization server may set a default scope there.
14431441
# RFC 9101 `request` and `request_uri` are dropped as well, though the flow sets neither: a server takes
14441442
# the whole authorization request from the object they carry, over every parameter in the query, and both are
14451443
# the client's to send, never an endpoint URL's to supply.
@@ -1454,7 +1452,6 @@ def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_chall
14541452
own_params << ["scope", scope] if scope
14551453
own_params << ["resource", resource] if resource
14561454
dropped_names = own_params.map(&:first) + ["request", "request_uri"]
1457-
dropped_names << "scope" if @provider.respond_to?(:scope_selector) && @provider.scope_selector
14581455

14591456
params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
14601457
uri.query = URI.encode_www_form(params + own_params)

‎lib/mcp/client/oauth/provider.rb‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,10 @@ module OAuth
3737
# `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
3838
# - `scope` - String of space-separated scopes to request when the server's
3939
# `WWW-Authenticate` does not specify one.
40-
# - `scope_selector` - Callable receiving a read-only Array of candidate scope tokens after
41-
# challenge/PRM/provider selection and `offline_access` augmentation. Return an Array of valid
42-
# scope tokens to replace them, or `nil` / `[]` to omit the URL's `scope` parameter. The AS may
43-
# still apply default scopes. Unsupported `offline_access` remains stripped; filtering challenged
44-
# scopes may leave the operation unauthorized.
40+
# - `scope_selector` - Callable receiving a read-only Array of PRM `scopes_supported` tokens when
41+
# those defaults are selected without a challenged scope. Return an Array containing a subset;
42+
# `[]` requests none of the PRM defaults. Challenged scopes and provider fallback bypass it.
43+
# The existing `offline_access` policy runs afterward; endpoint query parameters are unchanged.
4544
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
4645
# `client_information`, and `save_client_information(info)`. Defaults to
4746
# an `InMemoryStorage`. Persisted `client_information` is stamped with

0 commit comments

Comments
 (0)