Skip to content
Open
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
21 changes: 17 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.
- Request `offline_access` when the client declares the `refresh_token` grant and the authorization server advertises it in
`scopes_supported` (SEP-2207). The PRM scope selector below runs before this augmentation and cannot disable it.
Unsupported `offline_access` is stripped from every scope source.

```ruby
require "mcp"
Expand Down Expand Up @@ -99,7 +99,20 @@ Optional keyword arguments:
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, counted from the moment `run!` saves it, 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 for narrowing Protected Resource Metadata (PRM) defaults in the authorization-code flow.
It is invoked only when no nonempty challenged scope was supplied and PRM supplies the default `scopes_supported` list,
before `offline_access` augmentation, request validation, and client registration. It receives a read-only array of PRM tokens
and must return an array containing a subset; custom scopes and `nil` results raise `ArgumentError`. Return `[]` to request
none of those PRM defaults. Challenged scopes, including the step-up union, and provider fallback bypass the selector;
use `authorization_request_validator` to accept or refuse challenged scopes. With no selector, default behavior is unchanged.
This hook does not control `offline_access` augmentation or alter authorization-endpoint query parameters. Returning `[]`
does not guarantee an omitted `scope` parameter: refresh policy can add `offline_access`, and a prefilled endpoint scope
survives when the flow has no scope of its own. The AS can also apply defaults or reject the request
([RFC 6749 §3.3](https://www.rfc-editor.org/rfc/rfc6749#section-3.3)); inspect the granted scopes.
For example, with PRM defaults `mcp:read mcp:write`, `scope_selector: ->(scopes) { scopes & ["mcp:read"] }` narrows the
default request to `mcp:read`; `scope_selector: ->(_scopes) { [] }` requests no PRM defaults. If the AS supports
`offline_access` and the client declares `refresh_token`, either request still includes `offline_access` afterward.
- `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
42 changes: 31 additions & 11 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 @@ -264,7 +267,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)

ensure_pkce_supported!(as_metadata)

effective_scope = resolve_scope(scope: scope, prm: prm)
effective_scope = resolve_scope(scope: scope, prm: prm, select_prm_scope: true)
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)

# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
Expand Down Expand Up @@ -857,9 +860,8 @@ 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.
# Challenged scopes are authoritative for the operation and bypass the PRM scope selector.
# The validator can accept or refuse them, not quietly request fewer scopes.
#
# 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 @@ -1302,17 +1304,18 @@ 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.
def resolve_scope(scope:, prm:)
# MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback.
# Authorization-code clients may narrow only the PRM default, before `offline_access` augmentation.
def resolve_scope(scope:, prm:, select_prm_scope: false)
return scope if scope && !scope.empty?

# `prm` is nil on the legacy path, where nothing advertises scopes.
supported = prm && prm["scopes_supported"]
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
if supported.is_a?(Array) && !supported.empty?
return select_prm_scopes(supported) if select_prm_scope

return supported.join(" ")
end

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

Expand Down Expand Up @@ -1354,6 +1357,23 @@ def server_supports_offline_access?(as_metadata)
supported.is_a?(Array) && supported.include?("offline_access")
end

# Selects a subset of the PRM default without changing challenges, provider fallback, or refresh policy.
def select_prm_scopes(scopes)
selector = @provider.scope_selector if @provider.respond_to?(:scope_selector)
return scopes.join(" ") unless selector

candidates = scopes.map { |token| token.dup.freeze }.freeze
selected = selector.call(candidates)
valid = selected.is_a?(Array) && selected.all? do |token|
token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) && candidates.include?(token)
end
unless valid
raise ArgumentError, "scope_selector must return an Array containing only scopes from PRM scopes_supported."
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
11 changes: 11 additions & 0 deletions lib/mcp/client/oauth/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ module OAuth
# `run!` saves it, 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 PRM `scopes_supported` tokens when
# those defaults are selected without a challenged scope. Return an Array containing a subset;
# `[]` requests none of the PRM defaults. Challenged scopes and provider fallback bypass it.
# The existing `offline_access` policy runs afterward; endpoint query parameters are unchanged.
# - `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 @@ -108,6 +112,7 @@ class PendingAuthorizationStorageError < ArgumentError; end
attr_reader :client_metadata,
:redirect_uri,
:scope,
:scope_selector,
:storage,
:redirect_handler,
:callback_handler,
Expand All @@ -120,6 +125,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 @@ -147,6 +153,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)

unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
Expand All @@ -170,6 +180,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