Skip to content

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

Description

@kstevens715

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions