Skip to content

Commit 8ad80dd

Browse files
committed
Let a provider add parameters to the token requests it makes
## Motivation and Context `ClientCredentialsProvider` builds a fixed `client_credentials` token request: `grant_type`, `scope`, `resource`, and the client authentication that `Flow#post_to_token_endpoint` adds. An authorization server that requires further parameters, such as Auth0's `audience`, cannot be used through `oauth:` at all, and giving up `oauth:` also gives up the bearer header and the 401-driven retry that the transport provides. RFC 6749 Section 8.2 allows extension parameters on the token request, and the TypeScript SDK lets a provider shape its token request through `prepareTokenRequest`. `ClientCredentialsProvider.new` now takes `token_request_params:`, a Hash of String keys and values added to every token request the provider makes, and `Flow#post_to_token_endpoint` reads the same name by duck typing, so a `Provider` or `CrossAppAccessProvider` subclass that defines the method gets the same treatment on its authorization code exchange, refresh, or `jwt-bearer` request. `Provider.new` and `CrossAppAccessProvider.new` take the same keyword, held by `StorageBackedProvider` like `authorization_request_validator`, so every bundled provider refuses a bad value before anything is stored. The parameters go underneath the flow's own, so the SDK's values always win, and a key the SDK sets itself (`Flow::RESERVED_TOKEN_REQUEST_PARAMS`) is refused rather than silently overridden: with `ArgumentError` from the constructor, before any `client_information` is written, and again with `ArgumentError` from the flow, before the token request is sent. Only Strings are accepted because `URI.encode_www_form` encodes other scalars and Arrays in ways the caller did not write, and the provider keeps a frozen copy so a later change to the caller's Hash cannot alter what is sent. A Hash comparing keys by identity is refused too, since two equal keys are two entries there, sent twice by a provider method or silently collapsed into one by the copy. The flow raises `ArgumentError` rather than `AuthorizationError` because `MCP::Client::HTTP` treats a failed refresh as a reason to run the interactive flow, which would then fail the same way after the user signed in. Both refusals raise `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, so a caller can tell them from an unknown keyword, and the copy duplicates keys as well as values, since `Hash` copies only keys whose class is exactly `String`. Fixes #554. ## How Has This Been Tested? New tests cover every token request the parameters can ride (the `client_credentials` grant under all three client authentication methods, the authorization code exchange, refresh, and `jwt-bearer`), both refusal boundaries (the constructors of all three providers, and the flow for a provider defining the method), and the transport surfacing a refused hook from a refresh attempt without starting the interactive flow. All of them fail against the previous library. ## Breaking Changes None. The keyword is optional and the hook is opt-in; a provider that does not define `token_request_params` sends the same requests as before.
1 parent 41fbf05 commit 8ad80dd

11 files changed

Lines changed: 583 additions & 24 deletions

‎docs/_client/authorization.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ Optional keyword arguments:
112112
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
113113
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
114114
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
115+
- `token_request_params`: Hash of String keys and values added to every token request the provider makes,
116+
for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`.
117+
Defaults to `nil`, which adds nothing. The authorization request is not affected.
118+
A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity,
119+
or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`,
120+
when the provider is built, and the accepted Hash is copied and frozen.
121+
Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes;
122+
the flow checks the returned value by the same rules and raises the same error before the token request is sent.
115123

116124
{: .warning }
117125
> 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,
@@ -192,6 +200,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
192200
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
193201
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
194202
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
203+
# token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires)
195204
)
196205

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

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

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

249259
### Communication Security
250260

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ module OAuth
3737
# also take the algorithm as an explicit option).
3838
# - `scope` - String of space-separated scopes to request when the server's
3939
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
40-
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
41-
# `client_information`, and `save_client_information(info)`. Defaults to
42-
# an `InMemoryStorage`. The `client_id` / `client_secret` are written
43-
# into it so the token exchange reads them through the same path as
44-
# a pre-registered authorization-code client.
40+
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
41+
# and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
42+
# The `client_id` / `client_secret` are written into it so the token exchange reads
43+
# them through the same path as a pre-registered authorization-code client.
44+
# - `token_request_params` - Hash of String keys and values added to every
45+
# token request this provider makes, for parameters the authorization
46+
# server requires beyond the grant itself (Auth0's `audience`, for example).
47+
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
48+
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
4549
class ClientCredentialsProvider
4650
include StorageBackedProvider
4751

@@ -61,7 +65,8 @@ def initialize(
6165
signing_algorithm: nil,
6266
scope: nil,
6367
storage: nil,
64-
authorization_request_validator: nil
68+
authorization_request_validator: nil,
69+
token_request_params: nil
6570
)
6671
if blank?(client_id)
6772
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@@ -105,6 +110,7 @@ def initialize(
105110
@scope = scope
106111
@storage = storage || InMemoryStorage.new
107112
@authorization_request_validator = authorization_request_validator
113+
@token_request_params = frozen_token_request_params(token_request_params)
108114
@storage.save_client_information(client_information)
109115
end
110116

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ module OAuth
2525
# the Protected Resource Metadata do not specify one.
2626
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
2727
# Defaults to an `InMemoryStorage`.
28+
# - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters
29+
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
30+
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
31+
# See `StorageBackedProvider#token_request_params`.
2832
#
2933
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
3034
class CrossAppAccessProvider
@@ -35,7 +39,15 @@ class InvalidConfigurationError < ArgumentError; end
3539

3640
attr_reader :scope, :storage
3741

38-
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
42+
def initialize(
43+
client_id:,
44+
client_secret:,
45+
assertion_provider:,
46+
scope: nil,
47+
storage: nil,
48+
authorization_request_validator: nil,
49+
token_request_params: nil
50+
)
3951
if blank?(client_id)
4052
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
4153
end
@@ -52,6 +64,7 @@ def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, stor
5264
@scope = scope
5365
@storage = storage || InMemoryStorage.new
5466
@authorization_request_validator = authorization_request_validator
67+
@token_request_params = frozen_token_request_params(token_request_params)
5568
@storage.save_client_information(
5669
"client_id" => client_id,
5770
"client_secret" => client_secret,

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ class Flow
1717
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
1818
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
1919

20+
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
21+
# so a provider naming one of these is refused rather than left believing its value was sent.
22+
RESERVED_TOKEN_REQUEST_PARAMS = [
23+
"grant_type",
24+
"client_id",
25+
"client_secret",
26+
"client_assertion",
27+
"client_assertion_type",
28+
"scope",
29+
"resource",
30+
"code",
31+
"code_verifier",
32+
"redirect_uri",
33+
"refresh_token",
34+
"assertion",
35+
].freeze
36+
2037
class AuthorizationError < StandardError
2138
attr_reader :http_status, :error, :error_description
2239

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

60+
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
61+
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
62+
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
63+
# a failed refresh as a reason to run the interactive flow.
64+
class InvalidTokenRequestParamsError < ArgumentError; end
65+
66+
class << self
67+
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
68+
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
69+
# so the same problem reads the same wherever it surfaces.
70+
def token_request_params_problem(params)
71+
return "must be a Hash (got #{params.class})." unless params.is_a?(Hash)
72+
73+
# Two equal keys are two entries here, which would be sent twice from a provider method
74+
# or silently collapse into one when the constructors copy the Hash.
75+
return "must not compare keys by identity." if params.compare_by_identity?
76+
77+
params.each do |key, value|
78+
return "keys must be Strings (got #{key.class})." unless key.is_a?(String)
79+
return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String)
80+
return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key)
81+
end
82+
83+
nil
84+
end
85+
end
86+
4387
def initialize(provider:, http_client_factory: nil)
4488
@provider = provider
4589
@http_client_factory = http_client_factory || -> { default_http_client }
@@ -960,6 +1004,22 @@ def provider_authorization_flow
9601004
@provider.authorization_flow
9611005
end
9621006

1007+
# Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them;
1008+
# Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method,
1009+
# or one returning `nil`, adds nothing.
1010+
# A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do.
1011+
def provider_token_request_params
1012+
return {} unless @provider.respond_to?(:token_request_params)
1013+
1014+
params = @provider.token_request_params
1015+
return {} if params.nil?
1016+
1017+
problem = self.class.token_request_params_problem(params)
1018+
raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem
1019+
1020+
params
1021+
end
1022+
9631023
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
9641024
authorization_endpoint = as_metadata["authorization_endpoint"]
9651025
unless authorization_endpoint
@@ -1012,6 +1072,9 @@ def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:
10121072
# Submits a form-encoded token request using the authentication method
10131073
# stored in `client_information`. The method determines whether client
10141074
# credentials belong in the form body, a Basic header, or a JWT assertion.
1075+
# A provider's `token_request_params` go underneath the flow's own parameters,
1076+
# which therefore win, and are refused before the request is sent when they name
1077+
# a reserved parameter or are not a Hash of Strings.
10151078
def post_to_token_endpoint(as_metadata:, client_info:, form:)
10161079
client_id = client_info_required_value(client_info, "client_id")
10171080
unless client_id
@@ -1021,6 +1084,7 @@ def post_to_token_endpoint(as_metadata:, client_info:, form:)
10211084

10221085
client_secret = client_info_required_value(client_info, "client_secret")
10231086
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
1087+
form = provider_token_request_params.merge(form)
10241088

10251089
# Apply one client authentication method per request (RFC 6749 Section 2.3).
10261090
headers = {}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ module OAuth
4848
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
4949
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
5050
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
51+
# - `token_request_params` - Hash of String keys and values added to every token request this provider makes
52+
# (the authorization code exchange and refresh), for parameters the authorization server requires beyond
53+
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
54+
# raises `Flow::InvalidTokenRequestParamsError`.
55+
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
5156
class Provider
5257
include StorageBackedProvider
5358

@@ -87,7 +92,8 @@ def initialize(
8792
scope: nil,
8893
storage: nil,
8994
client_id_metadata_document_url: nil,
90-
authorization_request_validator: nil
95+
authorization_request_validator: nil,
96+
token_request_params: nil
9197
)
9298
unless Discovery.secure_url?(redirect_uri)
9399
raise InsecureRedirectURIError,
@@ -117,6 +123,7 @@ def initialize(
117123
@storage = storage || InMemoryStorage.new
118124
@client_id_metadata_document_url = client_id_metadata_document_url
119125
@authorization_request_validator = authorization_request_validator
126+
@token_request_params = frozen_token_request_params(token_request_params)
120127
end
121128

122129
# Identifies the OAuth flow this provider drives.

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ module MCP
44
class Client
55
module OAuth
66
# Shared token/credential persistence for the OAuth provider classes
7-
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
8-
# for the client_credentials flow). The two grants differ in how they authenticate,
9-
# but both read and write the same two pieces of state through a `storage` object:
10-
# the token response and the client information. This module supplies that delegation
11-
# so the `Flow` orchestrator can treat any provider uniformly.
7+
# (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
8+
# for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
9+
# The grants differ in how they authenticate, but all read and write the same two pieces of state
10+
# through a `storage` object: the token response and the client information. This module supplies
11+
# that delegation so the `Flow` orchestrator can treat any provider uniformly.
1212
#
1313
# Including classes must set `@storage` to an object responding to `tokens`,
1414
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
@@ -21,6 +21,13 @@ module StorageBackedProvider
2121
# MCP SDK has today.
2222
attr_reader :authorization_request_validator
2323

24+
# Optional Hash of String keys and values added to every token request the provider makes, for parameters
25+
# the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them;
26+
# Auth0's `audience` is the usual one). `nil` (the default) adds nothing.
27+
# Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value
28+
# with `Flow::InvalidTokenRequestParamsError`.
29+
attr_reader :token_request_params
30+
2431
def access_token
2532
tokens&.dig("access_token") || tokens&.dig(:access_token)
2633
end
@@ -44,6 +51,21 @@ def save_client_information(info)
4451
def clear_tokens!
4552
@storage.save_tokens(nil)
4653
end
54+
55+
private
56+
57+
# Constructors call this before writing to `storage`, so a rejected provider leaves it untouched.
58+
# The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key,
59+
# or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly
60+
# because `Hash` copies and freezes only keys whose class is exactly `String`.
61+
def frozen_token_request_params(params)
62+
return if params.nil?
63+
64+
problem = Flow.token_request_params_problem(params)
65+
raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem
66+
67+
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
68+
end
4769
end
4870
end
4971
end

0 commit comments

Comments
 (0)