Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2014,6 +2014,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`,
Expand Down
45 changes: 34 additions & 11 deletions conformance/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ def conformance_context
{}
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
Expand All @@ -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",
Expand All @@ -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 })
Expand Down
1 change: 0 additions & 1 deletion conformance/expected_failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,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
4 changes: 3 additions & 1 deletion lib/mcp/client/oauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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
Expand Down
89 changes: 89 additions & 0 deletions lib/mcp/client/oauth/client_credentials_provider.rb
Original file line number Diff line number Diff line change
@@ -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
66 changes: 57 additions & 9 deletions lib/mcp/client/oauth/flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
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)
Expand Down Expand Up @@ -92,14 +97,46 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
: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")
Expand All @@ -110,8 +147,7 @@ def refresh!(server_url:, resource_metadata_url: nil)

# 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
Expand Down Expand Up @@ -484,6 +520,18 @@ def wants_refresh_token?
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
Expand Down
37 changes: 12 additions & 25 deletions lib/mcp/client/oauth/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,6 +38,8 @@ module OAuth
# 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.
Expand Down Expand Up @@ -102,28 +106,11 @@ def initialize(
@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
Expand Down
Loading