Merge pull request #399 from koic/client_credentials_basic

Support OAuth `client_credentials` grant in OAuth client
This commit is contained in:
Koichi ITO 2026-06-13 20:06:29 +09:00 • committed by GitHub
commit cf27c5dca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 445 additions and 47 deletions

View file

@ -2017,6 +2017,29 @@ provider = MCP::Client::OAuth::Provider.new(
)
```
##### Client Credentials Grant
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: "my-service",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post"
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
)
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
```
Keyword arguments:
- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory.
- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
- `scope`, `storage`: Optional, same meaning as on `Provider`.
##### Communication Security
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,

View file

@ -51,6 +51,30 @@ rescue JSON::ParserError
{}
end
# Saves the pre-registered `client_id` / `client_secret` the harness injects
# via context (used by pre-registration and client_credentials scenarios).
def storage_for(context)
storage = MCP::Client::OAuth::InMemoryStorage.new
if context["client_id"]
storage.save_client_information(
"client_id" => context["client_id"],
"client_secret" => context["client_secret"],
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
)
end
storage
end
# Builds a `client_credentials`-only provider (machine-to-machine, no redirect).
# The pre-registered credentials are injected by the harness via context.
def build_client_credentials_provider(context)
MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: context["client_id"],
client_secret: context["client_secret"],
token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic",
)
end
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
# non-interactively against the conformance test's auth server. The conformance
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
@ -74,15 +98,6 @@ def build_oauth_provider(context, scenario:)
[query["code"], query["state"]]
end
storage = MCP::Client::OAuth::InMemoryStorage.new
if context["client_id"]
storage.save_client_information(
"client_id" => context["client_id"],
"client_secret" => context["client_secret"],
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
)
end
MCP::Client::OAuth::Provider.new(
client_metadata: {
client_name: "ruby-sdk-conformance-client",
@ -94,12 +109,20 @@ def build_oauth_provider(context, scenario:)
redirect_uri: redirect_uri,
redirect_handler: redirect_handler,
callback_handler: callback_handler,
storage: storage,
storage: storage_for(context),
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
)
end
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil
def build_provider_for(scenario, context)
if scenario.start_with?("auth/client-credentials")
build_client_credentials_provider(context)
else
build_oauth_provider(context, scenario: scenario)
end
end
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
client = MCP::Client.new(transport: transport)
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })

View file

@ -8,5 +8,4 @@ client:
- auth/2025-03-26-oauth-metadata-backcompat
- auth/2025-03-26-oauth-endpoint-fallback
- auth/client-credentials-jwt
- auth/client-credentials-basic
- auth/cross-app-access-complete-flow

View file

@ -4,13 +4,15 @@ require_relative "oauth/discovery"
require_relative "oauth/flow"
require_relative "oauth/in_memory_storage"
require_relative "oauth/pkce"
require_relative "oauth/storage_backed_provider"
require_relative "oauth/provider"
require_relative "oauth/client_credentials_provider"
module MCP
class Client
# OAuth client support for the MCP Authorization spec (PRM discovery,
# Authorization Server metadata discovery, Dynamic Client Registration,
# OAuth 2.1 Authorization Code + PKCE).
# OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
module OAuth
end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
module MCP
class Client
module OAuth
# OAuth client configuration for the OAuth 2.1 `client_credentials` grant
# (machine-to-machine, no user and no browser redirect). Handed to
# `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
# The interactive Authorization Code flow lives in `Provider`;
# this class exists so a credentials-only client never has to supply
# the redirect arguments that grant has no use for, mirroring the dedicated
# `ClientCredentialsProvider` in the TypeScript SDK and
# `ClientCredentialsOAuthProvider` in the Python SDK.
#
# Required keyword arguments:
#
# - `client_id` - String identifying the pre-registered confidential client.
# - `client_secret` - String shared secret. The `client_credentials` grant
# is for confidential clients, so a credential is mandatory.
#
# Optional keyword arguments:
#
# - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or
# `"client_secret_post"`. `"none"` is rejected: an unauthenticated
# `client_credentials` request is meaningless.
# - `scope` - String of space-separated scopes to request when the server's
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
# `client_information`, and `save_client_information(info)`. Defaults to
# an `InMemoryStorage`. The `client_id` / `client_secret` are written
# into it so the token exchange reads them through the same path as
# a pre-registered authorization-code client.
class ClientCredentialsProvider
include StorageBackedProvider
# Raised when the credentials required for the `client_credentials` grant are
# missing or the requested client authentication method cannot carry them.
class InvalidCredentialsError < ArgumentError; end
SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze
attr_reader :scope, :storage
def initialize(
client_id:,
client_secret:,
token_endpoint_auth_method: "client_secret_basic",
scope: nil,
storage: nil
)
if blank?(client_id)
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
end
unless SUPPORTED_AUTH_METHODS.include?(token_endpoint_auth_method)
raise InvalidCredentialsError,
"token_endpoint_auth_method must be one of #{SUPPORTED_AUTH_METHODS.inspect} for the " \
"client_credentials grant (got #{token_endpoint_auth_method.inspect}); an unauthenticated " \
"client_credentials request is not allowed."
end
if blank?(client_secret)
raise InvalidCredentialsError,
"client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
end
@scope = scope
@storage = storage || InMemoryStorage.new
@storage.save_client_information(
"client_id" => client_id,
"client_secret" => client_secret,
"token_endpoint_auth_method" => token_endpoint_auth_method,
)
end
# See `Provider#authorization_flow`.
def authorization_flow
:client_credentials
end
private
def blank?(value)
value.nil? || (value.is_a?(String) && value.strip.empty?)
end
end
end
end
end

View file

@ -54,6 +54,11 @@ module MCP
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
ensure_secure_endpoints!(as_metadata)
if provider_authorization_flow == :client_credentials
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
end
ensure_pkce_supported!(as_metadata)
client_info = ensure_client_registered(as_metadata: as_metadata)
@ -92,14 +97,46 @@ module MCP
:authorized
end
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
# Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
# token endpoint, and re-runs the audience / issuer / security checks
# before talking to it.
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
client_info = client_credentials_client_info
form = { "grant_type" => "client_credentials" }
effective_scope = resolve_scope(scope: scope, prm: prm)
form["scope"] = effective_scope if effective_scope
form["resource"] = resource if resource
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
@provider.save_tokens(tokens)
:authorized
end
# Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
# `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
# flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
# The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
# to dynamic registration.
def client_credentials_client_info
info = @provider.client_information
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
raise AuthorizationError,
"Cannot run the client_credentials grant: the provider has no stored `client_id`."
end
info
end
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
# checks before talking to it.
#
# Returns `:refreshed` on success. Raises `AuthorizationError` when
# the provider has no refresh token, no client information, or when
# the token endpoint refuses the refresh request.
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
# or when the token endpoint refuses the refresh request.
# https://www.rfc-editor.org/rfc/rfc6749#section-6
def refresh!(server_url:, resource_metadata_url: nil)
refresh_token = read_token("refresh_token")
@ -110,8 +147,7 @@ module MCP
# A CIMD-configured provider stores no `client_information` on purpose
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
# Allow refresh to proceed in that case so the `refresh_token` obtained via
# the CIMD flow remains usable.
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
unless have_stored_client_info || have_cimd_url
@ -484,6 +520,18 @@ module MCP
Array(grant_types).include?("refresh_token")
end
# The OAuth flow the provider drives. Dispatching on the provider's
# declared flow keeps `Flow` from second-guessing intent by parsing
# `client_metadata[:grant_types]` (which is protocol metadata for the
# authorization server, not an SDK control signal). A provider that
# predates this method is treated as the interactive authorization-code
# flow it was the only option for.
def provider_authorization_flow
return :authorization_code unless @provider.respond_to?(:authorization_flow)
@provider.authorization_flow
end
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint

View file

@ -3,9 +3,11 @@
module MCP
class Client
module OAuth
# Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
# the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
# and httpx.Auth-based provider in the Python SDK.
# Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
# handed to `MCP::Client::HTTP` via the `oauth:` keyword.
# Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider
# in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant,
# use `ClientCredentialsProvider` instead.
#
# Required keyword arguments:
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
@ -36,6 +38,8 @@ module MCP
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
class Provider
include StorageBackedProvider
# Raised when `Provider#initialize` is called with a `redirect_uri` that
# is neither HTTPS nor a loopback `http://` URL, per the MCP
# authorization spec's Communication Security requirement.
@ -102,28 +106,11 @@ module MCP
@client_id_metadata_document_url = client_id_metadata_document_url
end
def access_token
tokens&.dig("access_token") || tokens&.dig(:access_token)
end
def tokens
@storage.tokens
end
def save_tokens(tokens)
@storage.save_tokens(tokens)
end
def client_information
@storage.client_information
end
def save_client_information(info)
@storage.save_client_information(info)
end
def clear_tokens!
@storage.save_tokens(nil)
# Identifies the OAuth flow this provider drives.
# `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
# which is protocol metadata for the authorization server, not an SDK control signal.
def authorization_flow
:authorization_code
end
end
end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
module MCP
class Client
module OAuth
# Shared token/credential persistence for the OAuth provider classes
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
# for the client_credentials flow). The two grants differ in how they authenticate,
# but both read and write the same two pieces of state through a `storage` object:
# the token response and the client information. This module supplies that delegation
# so the `Flow` orchestrator can treat any provider uniformly.
#
# Including classes must set `@storage` to an object responding to `tokens`,
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
# (see `InMemoryStorage`).
module StorageBackedProvider
def access_token
tokens&.dig("access_token") || tokens&.dig(:access_token)
end
def tokens
@storage.tokens
end
def save_tokens(tokens)
@storage.save_tokens(tokens)
end
def client_information
@storage.client_information
end
def save_client_information(info)
@storage.save_client_information(info)
end
def clear_tokens!
@storage.save_tokens(nil)
end
end
end
end
end

View file

@ -0,0 +1,74 @@
# frozen_string_literal: true
require "test_helper"
require "mcp/client/oauth"
module MCP
class Client
module OAuth
class ClientCredentialsProviderTest < Minitest::Test
def test_initialize_stores_credentials_as_client_information
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
info = provider.client_information
assert_equal("cc-client", info["client_id"])
assert_equal("cc-secret", info["client_secret"])
assert_equal("client_secret_basic", info["token_endpoint_auth_method"])
end
def test_authorization_flow_is_client_credentials
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
assert_equal(:client_credentials, provider.authorization_flow)
end
def test_initialize_accepts_client_secret_post
provider = ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
token_endpoint_auth_method: "client_secret_post",
)
assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"])
end
def test_initialize_rejects_missing_client_id
["", " ", nil].each do |value|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do
ClientCredentialsProvider.new(client_id: value, client_secret: "cc-secret")
end
end
end
def test_initialize_rejects_missing_client_secret
# The client_credentials grant is for confidential clients, so a credential is mandatory.
["", " ", nil].each do |value|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: value)
end
end
end
def test_initialize_rejects_none_auth_method
# An unauthenticated client_credentials request is meaningless.
assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do
ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
token_endpoint_auth_method: "none",
)
end
end
def test_token_helpers_delegate_to_storage
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
provider.save_tokens("access_token" => "cc-token")
assert_equal("cc-token", provider.access_token)
provider.clear_tokens!
assert_nil(provider.tokens)
end
end
end
end
end

View file

@ -68,6 +68,14 @@ module MCP
WebMock.reset!
end
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic")
ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
token_endpoint_auth_method: token_endpoint_auth_method,
)
end
# Runs the full authorization flow and returns the `scope` query parameter
# sent on the authorization request. The caller stubs the AS metadata;
# this helper supplies a provider whose `grant_types` and optional pre-set
@ -96,6 +104,102 @@ module MCP
captured_scope
end
def test_run_uses_client_credentials_grant_for_client_credentials_provider
provider = client_credentials_provider
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_equal("test-token-from-flow", provider.access_token)
# No authorization-code machinery: /authorize is never contacted
# (no stub exists for it, so a request would raise), and DCR is not
# run because credentials are pre-registered.
assert_not_requested(:post, "#{@auth_base}/register")
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
expected_basic = "Basic " + Base64.strict_encode64("cc-client:cc-secret")
form["grant_type"] == "client_credentials" &&
!form.key?("code") &&
!form.key?("code_verifier") &&
req.headers["Authorization"] == expected_basic
end
end
def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body
# `client_secret_post` puts the credentials in the form body rather
# than an HTTP Basic header (RFC 6749 Section 2.3.1).
provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_post")
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "client_credentials" &&
form["client_id"] == "cc-client" &&
form["client_secret"] == "cc-secret" &&
req.headers["Authorization"].nil?
end
end
def test_run_client_credentials_raises_clean_error_when_client_information_missing
# The constructor always stores credentials, but if a custom storage loses them
# the grant must fail with a domain error rather than a `NoMethodError` from
# the authorization-code registration helper.
provider = client_credentials_provider
provider.storage.save_client_information(nil)
error = assert_raises(Flow::AuthorizationError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/client_credentials grant/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_client_credentials_requests_scope_from_prm_scopes_supported
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: "https://srv.example.com/mcp",
authorization_servers: [@auth_base],
scopes_supported: ["mcp:read", "mcp:write"],
),
)
provider = client_credentials_provider
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["scope"] == "mcp:read mcp:write"
end
end
def test_run_uses_authorization_code_grant_for_default_provider
# A standard `Provider` declares `authorization_flow == :authorization_code`,
# so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list.
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code", "client_credentials"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["grant_type"] == "authorization_code"
end
end
def test_run_completes_full_authorization_flow
captured_authorization_url = nil
state_value = nil

View file

@ -181,6 +181,12 @@ module MCP
end
end
end
def test_authorization_flow_is_authorization_code
provider = Provider.new(**args_for("https://app.example.com/callback"))
assert_equal(:authorization_code, provider.authorization_flow)
end
end
end
end