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 resource
and 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_headers sets Authorization from @oauth.access_token on every request
send_request rescues Faraday::UnauthorizedError and 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
end
Public 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.
Problem
ClientCredentialsProviderbuilds a fixed token request. InFlow#run_client_credentials!(v1.5.1):and
post_to_token_endpointmerges onlyclient_id(or theprivate_key_jwtassertion). 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@oauthThe second matters more than it looks.
send_requestbuilds the per-requestSSEStreamas the first statement inside itsbegin, soretrystarts a fresh buffer. A consumer who can't useoauth:must put the 401 retry elsewhere — and the obvious place, a Faraday middleware, is wrong. The middleware retries through the sameenv, whoseon_datais still the sameSSEStream#on_data. The 401 body stays in the buffer, the retried response is appended to it, andparse_json_buffergets 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_eoffiring 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:
respond_to?-guarded exactly likeclient_assertionalready 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 publicsend_requestso each attempt rebuilds the stream:Public API only, but it reimplements the SDK's own retry, and since the token never reaches
session_headerswe set the header on the connection ourselves via the customizer block from #303.Happy to open a PR for whichever shape you prefer.