Skip to content
Merged
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
14 changes: 12 additions & 2 deletions docs/_client/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ Optional keyword arguments:
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
- `token_request_params`: Hash of String keys and values added to every token request the provider makes,
for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`.
Defaults to `nil`, which adds nothing. The authorization request is not affected.
A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity,
or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`,
when the provider is built, and the accepted Hash is copied and frozen.
Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes;
the flow checks the returned value by the same rules and raises the same error before the token request is sent.

{: .warning }
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
Expand Down Expand Up @@ -192,6 +200,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
# token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires)
)

transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
Expand All @@ -207,7 +216,8 @@ Keyword arguments:
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`.
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.

### Cross-App Access (JWT Bearer) Grant

Expand Down Expand Up @@ -244,7 +254,7 @@ Keyword arguments:
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`.

### Communication Security

Expand Down
18 changes: 12 additions & 6 deletions lib/mcp/client/oauth/client_credentials_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ module OAuth
# also take the algorithm as an explicit option).
# - `scope` - String of space-separated scopes to request when the server's
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
# `client_information`, and `save_client_information(info)`. Defaults to
# an `InMemoryStorage`. The `client_id` / `client_secret` are written
# into it so the token exchange reads them through the same path as
# a pre-registered authorization-code client.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
# and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
# The `client_id` / `client_secret` are written into it so the token exchange reads
# them through the same path as a pre-registered authorization-code client.
# - `token_request_params` - Hash of String keys and values added to every
# token request this provider makes, for parameters the authorization
# server requires beyond the grant itself (Auth0's `audience`, for example).
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
class ClientCredentialsProvider
include StorageBackedProvider

Expand All @@ -61,7 +65,8 @@ def initialize(
signing_algorithm: nil,
scope: nil,
storage: nil,
authorization_request_validator: nil
authorization_request_validator: nil,
token_request_params: nil
)
if blank?(client_id)
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
Expand Down Expand Up @@ -105,6 +110,7 @@ def initialize(
@scope = scope
@storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@storage.save_client_information(client_information)
end

Expand Down
15 changes: 14 additions & 1 deletion lib/mcp/client/oauth/cross_app_access_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ module OAuth
# the Protected Resource Metadata do not specify one.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
# Defaults to an `InMemoryStorage`.
# - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
# See `StorageBackedProvider#token_request_params`.
#
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
class CrossAppAccessProvider
Expand All @@ -35,7 +39,15 @@ class InvalidConfigurationError < ArgumentError; end

attr_reader :scope, :storage

def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
def initialize(
client_id:,
client_secret:,
assertion_provider:,
scope: nil,
storage: nil,
authorization_request_validator: nil,
token_request_params: nil
)
if blank?(client_id)
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
end
Expand All @@ -52,6 +64,7 @@ def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, stor
@scope = scope
@storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@storage.save_client_information(
"client_id" => client_id,
"client_secret" => client_secret,
Expand Down
64 changes: 64 additions & 0 deletions lib/mcp/client/oauth/flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ class Flow
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512

# 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 = [
"grant_type",
"client_id",
"client_secret",
"client_assertion",
"client_assertion_type",
"scope",
"resource",
"code",
"code_verifier",
"redirect_uri",
"refresh_token",
"assertion",
].freeze

class AuthorizationError < StandardError
attr_reader :http_status, :error, :error_description

Expand All @@ -40,6 +57,33 @@ class InvalidGrantError < AuthorizationError; end
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
class AuthorizationRefusedError < AuthorizationError; end

# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
# a failed refresh as a reason to run the interactive flow.
class InvalidTokenRequestParamsError < ArgumentError; end

class << self
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
# so the same problem reads the same wherever it surfaces.
def token_request_params_problem(params)
return "must be a Hash (got #{params.class})." unless params.is_a?(Hash)

# Two equal keys are two entries here, which would be sent twice from a provider method
# or silently collapse into one when the constructors copy the Hash.
return "must not compare keys by identity." if params.compare_by_identity?

params.each do |key, value|
return "keys must be Strings (got #{key.class})." unless key.is_a?(String)
return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String)
return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key)
end

nil
end
end

def initialize(provider:, http_client_factory: nil)
@provider = provider
@http_client_factory = http_client_factory || -> { default_http_client }
Expand Down Expand Up @@ -960,6 +1004,22 @@ def provider_authorization_flow
@provider.authorization_flow
end

# Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them;
# Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method,
# or one returning `nil`, adds nothing.
# A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do.
def provider_token_request_params
return {} unless @provider.respond_to?(:token_request_params)

params = @provider.token_request_params
return {} if params.nil?

problem = self.class.token_request_params_problem(params)
raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem

params
end

def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint
Expand Down Expand Up @@ -1012,6 +1072,9 @@ def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:
# Submits a form-encoded token request using the authentication method
# stored in `client_information`. The method determines whether client
# credentials belong in the form body, a Basic header, or a JWT assertion.
# A provider's `token_request_params` go underneath the flow's own parameters,
# which therefore win, and are refused before the request is sent when they name
# a reserved parameter or are not a Hash of Strings.
def post_to_token_endpoint(as_metadata:, client_info:, form:)
client_id = client_info_required_value(client_info, "client_id")
unless client_id
Expand All @@ -1021,6 +1084,7 @@ def post_to_token_endpoint(as_metadata:, client_info:, form:)

client_secret = client_info_required_value(client_info, "client_secret")
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
form = provider_token_request_params.merge(form)

# Apply one client authentication method per request (RFC 6749 Section 2.3).
headers = {}
Expand Down
9 changes: 8 additions & 1 deletion lib/mcp/client/oauth/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ module OAuth
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
# - `token_request_params` - Hash of String keys and values added to every token request this provider makes
# (the authorization code exchange and refresh), for parameters the authorization server requires beyond
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
# raises `Flow::InvalidTokenRequestParamsError`.
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
class Provider
include StorageBackedProvider

Expand Down Expand Up @@ -87,7 +92,8 @@ def initialize(
scope: nil,
storage: nil,
client_id_metadata_document_url: nil,
authorization_request_validator: nil
authorization_request_validator: nil,
token_request_params: nil
)
unless Discovery.secure_url?(redirect_uri)
raise InsecureRedirectURIError,
Expand Down Expand Up @@ -117,6 +123,7 @@ def initialize(
@storage = storage || InMemoryStorage.new
@client_id_metadata_document_url = client_id_metadata_document_url
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
end

# Identifies the OAuth flow this provider drives.
Expand Down
32 changes: 27 additions & 5 deletions lib/mcp/client/oauth/storage_backed_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ module MCP
class Client
module OAuth
# Shared token/credential persistence for the OAuth provider classes
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
# for the client_credentials flow). The two grants differ in how they authenticate,
# but both read and write the same two pieces of state through a `storage` object:
# the token response and the client information. This module supplies that delegation
# so the `Flow` orchestrator can treat any provider uniformly.
# (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
# for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
# The grants differ in how they authenticate, but all read and write the same two pieces of state
# through a `storage` object: the token response and the client information. This module supplies
# that delegation so the `Flow` orchestrator can treat any provider uniformly.
#
# Including classes must set `@storage` to an object responding to `tokens`,
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
Expand All @@ -21,6 +21,13 @@ module StorageBackedProvider
# MCP SDK has today.
attr_reader :authorization_request_validator

# Optional Hash of String keys and values added to every token request the provider makes, for parameters
# the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them;
# Auth0's `audience` is the usual one). `nil` (the default) adds nothing.
# Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value
# with `Flow::InvalidTokenRequestParamsError`.
attr_reader :token_request_params

def access_token
tokens&.dig("access_token") || tokens&.dig(:access_token)
end
Expand All @@ -44,6 +51,21 @@ def save_client_information(info)
def clear_tokens!
@storage.save_tokens(nil)
end

private

# Constructors call this before writing to `storage`, so a rejected provider leaves it untouched.
# The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key,
# or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly
# because `Hash` copies and freezes only keys whose class is exactly `String`.
def frozen_token_request_params(params)
return if params.nil?

problem = Flow.token_request_params_problem(params)
raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem

params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
end
end
end
end
Expand Down
Loading