Skip to content

Let a provider add parameters to the token requests it makes - #555

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:add_token_request_params
Sep 18, 2026
Merged

koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:add_token_request_params

Conversation

@koic

@koic koic commented Sep 17, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## 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 modelcontextprotocol#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.
@kstevens715

Copy link
Copy Markdown

Thanks for turning this around so fast — token_request_params: covers what we needed, and duck-typing it in post_to_token_endpoint is a better shape than what I proposed.

One adjacent thing, for a separate change rather than this one. MCP::Client::HTTP builds the flow with Flow.new(provider: @oauth) at http.rb:850 and :870, so there's no way to reach the http_client_factory: that Flow already accepts. We record outbound HTTP into per-turn traces for incident debugging, and our own token service instruments its connection today, so moving to oauth: would drop discovery and token requests out of those traces. Once this lands, that's the last thing keeping us off oauth:.

Filed as #558 — happy to open a PR for it.

@koic

koic commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

Thanks for the offer. I've opened PR #560. This lands the capability in a different shape: http_client_customizer: on the provider rather than a factory on the transport, so the SDK keeps its connection defaults and can refuse a request that a customizer's middleware would send to another origin.

A recorder goes in as http_client_customizer: ->(faraday) { faraday.use MyApp::HttpRecorder }. The "Customizing the OAuth HTTP Client" section of docs/_client/authorization.md in that PR has the details and the constraints a customizer takes on.

@koic
koic merged commit f22ce86 into modelcontextprotocol:main Sep 18, 2026
11 checks passed
@koic
koic deleted the add_token_request_params branch September 18, 2026 02:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client_credentials token requests can't carry authorization-server-specific parameters

2 participants