Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/_client/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
- 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),
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.
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.
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
`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
`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.
- By default, request `offline_access` when the client declares the `refresh_token` grant and the authorization server advertises it in
`scopes_supported` (SEP-2207). This can enable refresh tokens. The optional `scope_selector` below can remove it, while a selector
cannot reintroduce `offline_access` when the authorization server does not advertise it.

```ruby
require "mcp"
Expand Down Expand Up @@ -98,7 +98,15 @@ Optional keyword arguments:
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
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).
- `pending_authorization_max_age`: Integer seconds a pending authorization stays redeemable after the redirect when `callback_handler` is omitted. Defaults to 600.
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `scope`: Space-separated fallback scopes when neither a challenge nor PRM advertises scopes.
- `scope_selector`: Optional callable invoked after the SDK chooses scopes from the challenge, PRM, or `scope` fallback and augments
`offline_access`, but before request validation and client registration. It receives a read-only array of candidate scope tokens;
return an array of valid OAuth scope tokens to replace them, or `nil` / `[]` to omit the authorization URL's `scope` parameter.
The default (`nil`) keeps the MCP scope-selection strategy unchanged. A selector can narrow or add custom scopes, but cannot
reintroduce unsupported `offline_access`. Filtering a challenged scope may leave the current operation unauthorized.
Return `[]` to omit the client's `scope` parameter on the first request, even if PRM advertises scopes. The AS can still apply
default scopes or reject the request ([RFC 6749 §3.3](https://www.rfc-editor.org/rfc/rfc6749#section-3.3)); check the granted
scope before treating the connection as unscoped, and allow later challenged scopes when needed.
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
- `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.
Expand Down
43 changes: 33 additions & 10 deletions lib/mcp/client/oauth/flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class Flow
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
METADATA_URL_MAX_LENGTH = 2048

# RFC 6749 scope-token: visible ASCII except space, double quote, and backslash.
SCOPE_TOKEN_FORMAT = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/.freeze

# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
# so a provider naming one of these is refused rather than left believing its value was sent.
RESERVED_TOKEN_REQUEST_PARAMS = [
Expand Down Expand Up @@ -266,6 +269,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)

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)

# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
# the embedding application has just rejected.
Expand Down Expand Up @@ -852,9 +856,9 @@ def ensure_same_origin!(url, label:, server_url:)
# places on MCP servers rather than on clients.
# A host that knows which providers its user deals with can apply that knowledge here.
#
# The scopes are passed on unchanged whatever the host decides, because the specification requires
# a client to treat the challenged scopes as authoritative for the operation; the choice offered is
# to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
# By default, challenged scopes pass through unchanged. An explicit scope selector may narrow
# them, accepting that the current operation could remain unauthorized. The validator sees the
# final selection and can still refuse the authorization before client registration.
#
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
Expand Down Expand Up @@ -1296,11 +1300,8 @@ def authorization_response_error(error, description)
AuthorizationError.new(message, error: error, error_description: description)
end

# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
# from the Protected Resource Metadata, and falls back to a provider-supplied
# scope only if both are absent. The provider-supplied scope must not pre-empt
# a server-advertised one.
# MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback.
# A provider's optional selector can adjust the result after `offline_access` augmentation.
def resolve_scope(scope:, prm:)
return scope if scope && !scope.empty?

Expand Down Expand Up @@ -1348,6 +1349,26 @@ def server_supports_offline_access?(as_metadata)
supported.is_a?(Array) && supported.include?("offline_access")
end

# Applies application policy after standard scope selection but before validation or registration.
def select_scope(scope, as_metadata:)
selector = @provider.scope_selector if @provider.respond_to?(:scope_selector)
return scope unless selector

selected = selector.call(scope.to_s.split.freeze)
valid = selected.is_a?(Array) &&
selected.all? { |token| token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) }
unless selected.nil? || valid
raise ArgumentError, "scope_selector must return nil or an Array of valid OAuth scope tokens."
end
return if selected.nil?

# Custom scopes are allowed, but unsupported offline_access remains refused.
unless server_supports_offline_access?(as_metadata)
selected = selected.reject { |token| token == "offline_access" }
end
selected.empty? ? nil : selected.join(" ")
end

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

params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
uri.query = URI.encode_www_form(params + own_params)
Expand Down
12 changes: 12 additions & 0 deletions lib/mcp/client/oauth/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ module OAuth
# when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
# - `scope` - String of space-separated scopes to request when the server's
# `WWW-Authenticate` does not specify one.
# - `scope_selector` - Callable receiving a read-only Array of candidate scope tokens after
# challenge/PRM/provider selection and `offline_access` augmentation. Return an Array of valid
# scope tokens to replace them, or `nil` / `[]` to omit the URL's `scope` parameter. The AS may
# still apply default scopes. Unsupported `offline_access` remains stripped; filtering challenged
# scopes may leave the operation unauthorized.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
# `client_information`, and `save_client_information(info)`. Defaults to
# an `InMemoryStorage`. Persisted `client_information` is stamped with
Expand Down Expand Up @@ -105,6 +110,7 @@ class PendingAuthorizationStorageError < ArgumentError; end
attr_reader :client_metadata,
:redirect_uri,
:scope,
:scope_selector,
:storage,
:redirect_handler,
:callback_handler,
Expand All @@ -117,6 +123,7 @@ def initialize(
redirect_handler:,
callback_handler: nil,
scope: nil,
scope_selector: nil,
storage: nil,
client_id_metadata_document_url: nil,
authorization_request_validator: nil,
Expand Down Expand Up @@ -144,6 +151,10 @@ def initialize(
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
end

unless scope_selector.nil? || scope_selector.respond_to?(:call)
raise ArgumentError, "scope_selector must respond to call (got #{scope_selector.class})."
end

http_client_customizer = validated_http_client_customizer(http_client_customizer)

storage ||= InMemoryStorage.new
Expand All @@ -167,6 +178,7 @@ def initialize(
@redirect_handler = redirect_handler
@callback_handler = callback_handler
@scope = scope
@scope_selector = scope_selector
@storage = storage
@client_id_metadata_document_url = client_id_metadata_document_url
@authorization_request_validator = authorization_request_validator
Expand Down
Loading
Loading