Skip to content

Commit 201f99a

Browse files
authored
Merge pull request #579 from atesgoral/ag/oauth-explicit-empty-scope
Add a scope selector for OAuth authorization-code clients
2 parents 314dd18 + 75c6c24 commit 201f99a

6 files changed

Lines changed: 498 additions & 39 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 28 additions & 7 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-
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
51-
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
52-
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives 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 resolved challenge, PRM, and provider scopes.
5353

5454
```ruby
5555
require "mcp"
@@ -99,7 +99,22 @@ Optional keyword arguments:
9999
Omit it when the redirect arrives in a later request, as it does in a web application; see [Authorization in Web Applications](#authorization-in-web-applications).
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.
102-
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
102+
- `scope`: Space-separated fallback scopes when neither a challenge nor PRM advertises scopes.
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+
Malformed PRM tokens raise `Flow::AuthorizationError` before the callback. It must return an array containing a subset;
107+
custom scopes and `nil` results raise `ArgumentError`. Return `[]` to request
108+
none of those PRM defaults. Challenged scopes, including the step-up union, and provider fallback bypass the selector;
109+
use `authorization_request_validator` to accept or refuse challenged scopes. With no selector, default behavior is unchanged.
110+
This hook does not control `offline_access` augmentation or alter authorization-endpoint query parameters. Returning `[]`
111+
does not guarantee an omitted `scope` parameter: refresh policy can add `offline_access`, and a prefilled endpoint scope
112+
survives when the flow has no scope of its own and is included in the validator's scope list. Repeated endpoint `scope`
113+
parameters that would survive are rejected before registration. The authorization server can also apply defaults or reject
114+
the request ([RFC 6749 Section 3.3](https://www.rfc-editor.org/rfc/rfc6749#section-3.3)); inspect the granted scopes.
115+
For example, with PRM defaults `mcp:read mcp:write`, `scope_selector: ->(scopes) { scopes & ["mcp:read"] }` narrows the
116+
default request to `mcp:read`; `scope_selector: ->(_scopes) { [] }` requests no PRM defaults. If the authorization server supports
117+
`offline_access` and the client declares `refresh_token`, either request still includes `offline_access` afterward.
103118
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
104119
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
105120
- `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.
@@ -436,8 +451,8 @@ provider = MCP::Client::OAuth::Provider.new(
436451
)
437452
```
438453

439-
The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither
440-
the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification
454+
The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array of explicit requested scope tokens),
455+
`server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification
441456
can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide
442457
(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes.
443458

@@ -450,7 +465,10 @@ Compare the issuer as a whole string, the way the SDK compares it everywhere els
450465
and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising.
451466

452467
The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has
453-
any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it:
468+
any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it. On the authorization-code flow,
469+
this includes any single prefilled endpoint scope that survives when the flow has no scope of its own, including after a selector returns `[]`.
470+
The query names are decoded the same way as the URL builder; repeated `scope` parameters that would survive are rejected before registration.
471+
The following policy therefore checks the explicit scopes that the authorization URL will send:
454472

455473
```ruby
456474
ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] }
@@ -463,6 +481,9 @@ provider = MCP::Client::OAuth::Provider.new(
463481
)
464482
```
465483

484+
In this example, an empty `request.scopes` approves a request with no explicit scope tokens. This is not a ceiling on what the authorization server may grant:
485+
it can still apply defaults, so inspect the granted token scopes before treating a connection as unscoped.
486+
466487
A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them.
467488

468489
It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential,

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

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class Flow
1919
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
2020
METADATA_URL_MAX_LENGTH = 2048
2121

22+
# RFC 6749 scope-token: visible ASCII except space, double quote, and backslash.
23+
SCOPE_TOKEN_FORMAT = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/.freeze
24+
2225
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
2326
# so a provider naming one of these is refused rather than left believing its value was sent.
2427
RESERVED_TOKEN_REQUEST_PARAMS = [
@@ -264,20 +267,23 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
264267

265268
ensure_pkce_supported!(as_metadata)
266269

267-
effective_scope = resolve_scope(scope: scope, prm: prm)
270+
effective_scope = resolve_scope(scope: scope, prm: prm, select_prm_scope: true)
268271
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
272+
endpoint_uri, endpoint_params = authorization_endpoint_parameters(as_metadata: as_metadata)
273+
request_scope = authorization_request_scope(scope: effective_scope, endpoint_params: endpoint_params)
269274

270275
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
271-
# the embedding application has just rejected.
272-
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
276+
# the embedding application has just rejected. Use the scopes that will actually reach the browser URL.
277+
authorize_request!(as_metadata: as_metadata, scope: request_scope, server_url: server_url, resource: resource)
273278

274279
client_info = ensure_client_registered(as_metadata: as_metadata)
275280

276281
pkce = PKCE.generate
277282
state = SecureRandom.urlsafe_base64(32)
278283

279284
authorization_url = build_authorization_url(
280-
as_metadata: as_metadata,
285+
endpoint_uri: endpoint_uri,
286+
endpoint_params: endpoint_params,
281287
client_id: client_info_required_value(client_info, "client_id"),
282288
scope: effective_scope,
283289
state: state,
@@ -850,16 +856,16 @@ def ensure_same_origin!(url, label:, server_url:)
850856
# Hands the embedding application the authorization server and the scopes that are about to be requested,
851857
# and abandons the flow when it refuses them.
852858
#
853-
# Both values are chosen by the MCP server: it names its own authorization server in Protected
854-
# Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge.
859+
# The MCP server names its authorization server in Protected Resource Metadata and states scopes
860+
# in `scopes_supported` or the `WWW-Authenticate` challenge. Authorization-code policy also sees
861+
# any prefilled endpoint scope that will survive URL assembly.
855862
# Neither the specification nor any MCP SDK binds that choice to the server's own identity,
856863
# and validating that a token was issued for the intended audience is a responsibility the specification
857864
# places on MCP servers rather than on clients.
858865
# A host that knows which providers its user deals with can apply that knowledge here.
859866
#
860-
# The scopes are passed on unchanged whatever the host decides, because the specification requires
861-
# a client to treat the challenged scopes as authoritative for the operation; the choice offered is
862-
# to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
867+
# Challenged scopes are authoritative for the operation and bypass the PRM scope selector.
868+
# The validator can accept or refuse them, not quietly request fewer scopes.
863869
#
864870
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
865871
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
@@ -1302,17 +1308,18 @@ def authorization_response_error(error, description)
13021308
AuthorizationError.new(message, error: error, error_description: description)
13031309
end
13041310

1305-
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
1306-
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
1307-
# from the Protected Resource Metadata, and falls back to a provider-supplied
1308-
# scope only if both are absent. The provider-supplied scope must not pre-empt
1309-
# a server-advertised one.
1310-
def resolve_scope(scope:, prm:)
1311+
# MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback.
1312+
# Authorization-code clients may narrow only the PRM default, before `offline_access` augmentation.
1313+
def resolve_scope(scope:, prm:, select_prm_scope: false)
13111314
return scope if scope && !scope.empty?
13121315

13131316
# `prm` is nil on the legacy path, where nothing advertises scopes.
13141317
supported = prm && prm["scopes_supported"]
1315-
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
1318+
if supported.is_a?(Array) && !supported.empty?
1319+
return select_prm_scopes(supported) if select_prm_scope
1320+
1321+
return supported.join(" ")
1322+
end
13161323

13171324
return @provider.scope if @provider.scope && !@provider.scope.empty?
13181325

@@ -1334,7 +1341,8 @@ def resolve_scope(scope:, prm:)
13341341
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
13351342
# own request consistent with the AS's advertisement.
13361343
#
1337-
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
1344+
# Returns `nil` when empty so the URL builder adds no flow-owned scope parameter; a prefilled scope
1345+
# can still survive and is checked by the authorization-request validator.
13381346
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
13391347
def normalize_offline_access_scope(scope, as_metadata:)
13401348
scopes = scope.to_s.split
@@ -1354,6 +1362,28 @@ def server_supports_offline_access?(as_metadata)
13541362
supported.is_a?(Array) && supported.include?("offline_access")
13551363
end
13561364

1365+
# Selects a subset of the PRM default without changing challenges, provider fallback, or refresh policy.
1366+
def select_prm_scopes(scopes)
1367+
selector = @provider.scope_selector if @provider.respond_to?(:scope_selector)
1368+
return scopes.join(" ") unless selector
1369+
1370+
unless scopes.all? { |token| token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) }
1371+
raise AuthorizationError, "Protected Resource Metadata `scopes_supported` contains invalid OAuth scope tokens."
1372+
end
1373+
1374+
candidates = scopes.map { |token| token.dup.freeze }.freeze
1375+
candidate_index = candidates.to_h { |token| [token, true] }
1376+
selected = selector.call(candidates)
1377+
valid = selected.is_a?(Array) && selected.all? do |token|
1378+
token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) && candidate_index.key?(token)
1379+
end
1380+
unless valid
1381+
raise ArgumentError, "scope_selector must return an Array containing only scopes from PRM scopes_supported."
1382+
end
1383+
1384+
selected.empty? ? nil : selected.join(" ")
1385+
end
1386+
13571387
def wants_refresh_token?
13581388
metadata = @provider.client_metadata
13591389
grant_types = metadata[:grant_types] || metadata["grant_types"]
@@ -1398,7 +1428,7 @@ def provider_client_id_metadata_document_url
13981428
@provider.client_id_metadata_document_url
13991429
end
14001430

1401-
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
1431+
def authorization_endpoint_parameters(as_metadata:)
14021432
authorization_endpoint = as_metadata["authorization_endpoint"]
14031433
unless authorization_endpoint
14041434
raise AuthorizationError,
@@ -1412,6 +1442,23 @@ def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_chall
14121442
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
14131443
end
14141444

1445+
[uri, URI.decode_www_form(uri.query.to_s)]
1446+
end
1447+
1448+
# A flow scope replaces every endpoint scope; otherwise a single prefilled value survives.
1449+
# Decode once before policy approval and reuse those parameters when building the URL.
1450+
def authorization_request_scope(scope:, endpoint_params:)
1451+
return scope if scope
1452+
1453+
endpoint_scopes = endpoint_params.filter_map { |name, value| value if name == "scope" }
1454+
if endpoint_scopes.length > 1
1455+
raise AuthorizationError, "Authorization endpoint contains repeated `scope` parameters."
1456+
end
1457+
1458+
endpoint_scopes.first
1459+
end
1460+
1461+
def build_authorization_url(endpoint_uri:, endpoint_params:, client_id:, scope:, state:, code_challenge:, resource:)
14151462
# A parameter the flow sets replaces any of the same name the endpoint URL already carries.
14161463
# RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
14171464
# its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
@@ -1433,10 +1480,10 @@ def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_chall
14331480
own_params << ["resource", resource] if resource
14341481
dropped_names = own_params.map(&:first) + ["request", "request_uri"]
14351482

1436-
params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
1437-
uri.query = URI.encode_www_form(params + own_params)
1483+
params = endpoint_params.reject { |name, _value| dropped_names.include?(name) }
1484+
endpoint_uri.query = URI.encode_www_form(params + own_params)
14381485

1439-
uri
1486+
endpoint_uri
14401487
end
14411488

14421489
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:, redirect_uri: @provider.redirect_uri)

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +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 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.
4044
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
4145
# `client_information`, and `save_client_information(info)`. Defaults to
4246
# an `InMemoryStorage`. Persisted `client_information` is stamped with
@@ -108,6 +112,7 @@ class PendingAuthorizationStorageError < ArgumentError; end
108112
attr_reader :client_metadata,
109113
:redirect_uri,
110114
:scope,
115+
:scope_selector,
111116
:storage,
112117
:redirect_handler,
113118
:callback_handler,
@@ -120,6 +125,7 @@ def initialize(
120125
redirect_handler:,
121126
callback_handler: nil,
122127
scope: nil,
128+
scope_selector: nil,
123129
storage: nil,
124130
client_id_metadata_document_url: nil,
125131
authorization_request_validator: nil,
@@ -147,6 +153,10 @@ def initialize(
147153
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
148154
end
149155

156+
unless scope_selector.nil? || scope_selector.respond_to?(:call)
157+
raise ArgumentError, "scope_selector must respond to call (got #{scope_selector.class})."
158+
end
159+
150160
http_client_customizer = validated_http_client_customizer(http_client_customizer)
151161

152162
unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
@@ -170,6 +180,7 @@ def initialize(
170180
@redirect_handler = redirect_handler
171181
@callback_handler = callback_handler
172182
@scope = scope
183+
@scope_selector = scope_selector
173184
@storage = storage
174185
@client_id_metadata_document_url = client_id_metadata_document_url
175186
@authorization_request_validator = authorization_request_validator

0 commit comments

Comments
 (0)