Repository navigation
client_credentials token requests can't carry authorization-server-specific parameters #554
Description
Problem
ClientCredentialsProvider builds a fixed token request. In Flow#run_client_credentials! (v1.5.1):
form = { "grant_type" => "client_credentials" }
effective_scope = resolve_scope(scope: scope, prm: prm)
authorize_request!(...)
form["scope"] = effective_scope if effective_scope
form["resource"] = resource if resourceand post_to_token_endpoint merges only client_id (or the private_key_jwt assertion). A provider has no way to add anything.
Our authorization server requires two extra parameters identifying the calling application and its environment. Because we can't add them, we can't use oauth: at all.
Why that costs more than the token request
Opting out of oauth: opts out of everything else keyed on it:
session_headerssetsAuthorizationfrom@oauth.access_tokenon every requestsend_requestrescuesFaraday::UnauthorizedErrorand retries, gated on@oauth
The second matters more than it looks. send_request builds the per-request SSEStream as the first statement inside its begin, so retry starts a fresh buffer. A consumer who can't use oauth: must put the 401 retry elsewhere — and the obvious place, a Faraday middleware, is wrong. The middleware retries through the same env, whose on_data is still the same SSEStream#on_data. The 401 body stays in the buffer, the retried response is appended to it, and parse_json_buffer gets two concatenated JSON documents.
We shipped exactly that and it broke every call once tokens began needing refresh. It reads like a truncated response but is the opposite: json_ensure_eof firing on trailing bytes after a complete document.
So the missing hook doesn't just block a parameter — it steers consumers toward a retry placement that quietly corrupts the stream buffer.
Proposal
Let a provider contribute extra token-request parameters:
form.merge!(@provider.additional_token_params) if @provider.respond_to?(:additional_token_params)respond_to?-guarded exactly like client_assertion already is, so existing providers are unaffected. Reserved keys could be rejected or simply win.
Alternative
A broader version: let a provider own token acquisition outright. Some consumers already have a token service with its own caching and rotation and just want the SDK to call it. Today the only way in is overriding the private run_oauth_flow!.
Current workaround
Skip oauth:, subclass the transport, and retry around the public send_request so each attempt rebuilds the stream:
def send_request(request:)
retried = false
begin
super
rescue MCP::Client::RequestHandlerError => e
raise unless e.error_type == :unauthorized && !retried
retried = true
raise unless refresh_token_from(e)
retry
end
endPublic API only, but it reimplements the SDK's own retry, and since the token never reaches session_headers we set the header on the connection ourselves via the customizer block from #303.
Happy to open a PR for whichever shape you prefer.
Activity
koic commentedon Sep 17, 2026
Thanks for the report. #555 adds
token_request_params:toClientCredentialsProvider(and to the other providers), accepting a Hash of String keys and values that are added to every token request. This allowsoauth:to keep handling the bearer header and the 401 retry.Let a provider add parameters to the token requests it makes