Skip to content

Commit cf27c5d

Browse files
authored
Merge pull request #399 from koic/client_credentials_basic
Support OAuth `client_credentials` grant in OAuth client
2 parents 81e34cb + 2289db8 commit cf27c5d

11 files changed

Lines changed: 445 additions & 47 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2017,6 +2017,29 @@ provider = MCP::Client::OAuth::Provider.new(
20172017
)
20182018
```
20192019

2020+
##### Client Credentials Grant
2021+
2022+
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
2023+
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
2024+
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
2025+
2026+
```ruby
2027+
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
2028+
client_id: "my-service",
2029+
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
2030+
# token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post"
2031+
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
2032+
)
2033+
2034+
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
2035+
```
2036+
2037+
Keyword arguments:
2038+
2039+
- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory.
2040+
- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
2041+
- `scope`, `storage`: Optional, same meaning as on `Provider`.
2042+
20202043
##### Communication Security
20212044

20222045
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,

conformance/client.rb

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,30 @@ def conformance_context
5151
{}
5252
end
5353

54+
# Saves the pre-registered `client_id` / `client_secret` the harness injects
55+
# via context (used by pre-registration and client_credentials scenarios).
56+
def storage_for(context)
57+
storage = MCP::Client::OAuth::InMemoryStorage.new
58+
if context["client_id"]
59+
storage.save_client_information(
60+
"client_id" => context["client_id"],
61+
"client_secret" => context["client_secret"],
62+
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
63+
)
64+
end
65+
storage
66+
end
67+
68+
# Builds a `client_credentials`-only provider (machine-to-machine, no redirect).
69+
# The pre-registered credentials are injected by the harness via context.
70+
def build_client_credentials_provider(context)
71+
MCP::Client::OAuth::ClientCredentialsProvider.new(
72+
client_id: context["client_id"],
73+
client_secret: context["client_secret"],
74+
token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic",
75+
)
76+
end
77+
5478
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
5579
# non-interactively against the conformance test's auth server. The conformance
5680
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
@@ -74,15 +98,6 @@ def build_oauth_provider(context, scenario:)
7498
[query["code"], query["state"]]
7599
end
76100

77-
storage = MCP::Client::OAuth::InMemoryStorage.new
78-
if context["client_id"]
79-
storage.save_client_information(
80-
"client_id" => context["client_id"],
81-
"client_secret" => context["client_secret"],
82-
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
83-
)
84-
end
85-
86101
MCP::Client::OAuth::Provider.new(
87102
client_metadata: {
88103
client_name: "ruby-sdk-conformance-client",
@@ -94,12 +109,20 @@ def build_oauth_provider(context, scenario:)
94109
redirect_uri: redirect_uri,
95110
redirect_handler: redirect_handler,
96111
callback_handler: callback_handler,
97-
storage: storage,
112+
storage: storage_for(context),
98113
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
99114
)
100115
end
101116

102-
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil
117+
def build_provider_for(scenario, context)
118+
if scenario.start_with?("auth/client-credentials")
119+
build_client_credentials_provider(context)
120+
else
121+
build_oauth_provider(context, scenario: scenario)
122+
end
123+
end
124+
125+
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
103126
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
104127
client = MCP::Client.new(transport: transport)
105128
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })

conformance/expected_failures.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,4 @@ client:
88
- auth/2025-03-26-oauth-metadata-backcompat
99
- auth/2025-03-26-oauth-endpoint-fallback
1010
- auth/client-credentials-jwt
11-
- auth/client-credentials-basic
1211
- auth/cross-app-access-complete-flow

lib/mcp/client/oauth.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
require_relative "oauth/flow"
55
require_relative "oauth/in_memory_storage"
66
require_relative "oauth/pkce"
7+
require_relative "oauth/storage_backed_provider"
78
require_relative "oauth/provider"
9+
require_relative "oauth/client_credentials_provider"
810

911
module MCP
1012
class Client
1113
# OAuth client support for the MCP Authorization spec (PRM discovery,
1214
# Authorization Server metadata discovery, Dynamic Client Registration,
13-
# OAuth 2.1 Authorization Code + PKCE).
15+
# OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
1416
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
1517
module OAuth
1618
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
module OAuth
6+
# OAuth client configuration for the OAuth 2.1 `client_credentials` grant
7+
# (machine-to-machine, no user and no browser redirect). Handed to
8+
# `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
9+
# The interactive Authorization Code flow lives in `Provider`;
10+
# this class exists so a credentials-only client never has to supply
11+
# the redirect arguments that grant has no use for, mirroring the dedicated
12+
# `ClientCredentialsProvider` in the TypeScript SDK and
13+
# `ClientCredentialsOAuthProvider` in the Python SDK.
14+
#
15+
# Required keyword arguments:
16+
#
17+
# - `client_id` - String identifying the pre-registered confidential client.
18+
# - `client_secret` - String shared secret. The `client_credentials` grant
19+
# is for confidential clients, so a credential is mandatory.
20+
#
21+
# Optional keyword arguments:
22+
#
23+
# - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or
24+
# `"client_secret_post"`. `"none"` is rejected: an unauthenticated
25+
# `client_credentials` request is meaningless.
26+
# - `scope` - String of space-separated scopes to request when the server's
27+
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
28+
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
29+
# `client_information`, and `save_client_information(info)`. Defaults to
30+
# an `InMemoryStorage`. The `client_id` / `client_secret` are written
31+
# into it so the token exchange reads them through the same path as
32+
# a pre-registered authorization-code client.
33+
class ClientCredentialsProvider
34+
include StorageBackedProvider
35+
36+
# Raised when the credentials required for the `client_credentials` grant are
37+
# missing or the requested client authentication method cannot carry them.
38+
class InvalidCredentialsError < ArgumentError; end
39+
40+
SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze
41+
42+
attr_reader :scope, :storage
43+
44+
def initialize(
45+
client_id:,
46+
client_secret:,
47+
token_endpoint_auth_method: "client_secret_basic",
48+
scope: nil,
49+
storage: nil
50+
)
51+
if blank?(client_id)
52+
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
53+
end
54+
55+
unless SUPPORTED_AUTH_METHODS.include?(token_endpoint_auth_method)
56+
raise InvalidCredentialsError,
57+
"token_endpoint_auth_method must be one of #{SUPPORTED_AUTH_METHODS.inspect} for the " \
58+
"client_credentials grant (got #{token_endpoint_auth_method.inspect}); an unauthenticated " \
59+
"client_credentials request is not allowed."
60+
end
61+
62+
if blank?(client_secret)
63+
raise InvalidCredentialsError,
64+
"client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
65+
end
66+
67+
@scope = scope
68+
@storage = storage || InMemoryStorage.new
69+
@storage.save_client_information(
70+
"client_id" => client_id,
71+
"client_secret" => client_secret,
72+
"token_endpoint_auth_method" => token_endpoint_auth_method,
73+
)
74+
end
75+
76+
# See `Provider#authorization_flow`.
77+
def authorization_flow
78+
:client_credentials
79+
end
80+
81+
private
82+
83+
def blank?(value)
84+
value.nil? || (value.is_a?(String) && value.strip.empty?)
85+
end
86+
end
87+
end
88+
end
89+
end

lib/mcp/client/oauth/flow.rb

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
5454
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
5555
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
5656
ensure_secure_endpoints!(as_metadata)
57+
58+
if provider_authorization_flow == :client_credentials
59+
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
60+
end
61+
5762
ensure_pkce_supported!(as_metadata)
5863

5964
client_info = ensure_client_registered(as_metadata: as_metadata)
@@ -92,14 +97,46 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
9297
:authorized
9398
end
9499

95-
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
96-
# Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
97-
# token endpoint, and re-runs the audience / issuer / security checks
98-
# before talking to it.
100+
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
101+
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
102+
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
103+
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
104+
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
105+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
106+
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
107+
client_info = client_credentials_client_info
108+
109+
form = { "grant_type" => "client_credentials" }
110+
effective_scope = resolve_scope(scope: scope, prm: prm)
111+
form["scope"] = effective_scope if effective_scope
112+
form["resource"] = resource if resource
113+
114+
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
115+
@provider.save_tokens(tokens)
116+
:authorized
117+
end
118+
119+
# Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
120+
# `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
121+
# flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
122+
# The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
123+
# to dynamic registration.
124+
def client_credentials_client_info
125+
info = @provider.client_information
126+
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
127+
raise AuthorizationError,
128+
"Cannot run the client_credentials grant: the provider has no stored `client_id`."
129+
end
130+
131+
info
132+
end
133+
134+
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
135+
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
136+
# checks before talking to it.
99137
#
100-
# Returns `:refreshed` on success. Raises `AuthorizationError` when
101-
# the provider has no refresh token, no client information, or when
102-
# the token endpoint refuses the refresh request.
138+
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
139+
# or when the token endpoint refuses the refresh request.
103140
# https://www.rfc-editor.org/rfc/rfc6749#section-6
104141
def refresh!(server_url:, resource_metadata_url: nil)
105142
refresh_token = read_token("refresh_token")
@@ -110,8 +147,7 @@ def refresh!(server_url:, resource_metadata_url: nil)
110147

111148
# A CIMD-configured provider stores no `client_information` on purpose
112149
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
113-
# Allow refresh to proceed in that case so the `refresh_token` obtained via
114-
# the CIMD flow remains usable.
150+
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
115151
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
116152

117153
unless have_stored_client_info || have_cimd_url
@@ -484,6 +520,18 @@ def wants_refresh_token?
484520
Array(grant_types).include?("refresh_token")
485521
end
486522

523+
# The OAuth flow the provider drives. Dispatching on the provider's
524+
# declared flow keeps `Flow` from second-guessing intent by parsing
525+
# `client_metadata[:grant_types]` (which is protocol metadata for the
526+
# authorization server, not an SDK control signal). A provider that
527+
# predates this method is treated as the interactive authorization-code
528+
# flow it was the only option for.
529+
def provider_authorization_flow
530+
return :authorization_code unless @provider.respond_to?(:authorization_flow)
531+
532+
@provider.authorization_flow
533+
end
534+
487535
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
488536
authorization_endpoint = as_metadata["authorization_endpoint"]
489537
unless authorization_endpoint

lib/mcp/client/oauth/provider.rb

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
module MCP
44
class Client
55
module OAuth
6-
# Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
7-
# the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
8-
# and httpx.Auth-based provider in the Python SDK.
6+
# Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
7+
# handed to `MCP::Client::HTTP` via the `oauth:` keyword.
8+
# Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider
9+
# in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant,
10+
# use `ClientCredentialsProvider` instead.
911
#
1012
# Required keyword arguments:
1113
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
@@ -36,6 +38,8 @@ module OAuth
3638
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
3739
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
3840
class Provider
41+
include StorageBackedProvider
42+
3943
# Raised when `Provider#initialize` is called with a `redirect_uri` that
4044
# is neither HTTPS nor a loopback `http://` URL, per the MCP
4145
# authorization spec's Communication Security requirement.
@@ -102,28 +106,11 @@ def initialize(
102106
@client_id_metadata_document_url = client_id_metadata_document_url
103107
end
104108

105-
def access_token
106-
tokens&.dig("access_token") || tokens&.dig(:access_token)
107-
end
108-
109-
def tokens
110-
@storage.tokens
111-
end
112-
113-
def save_tokens(tokens)
114-
@storage.save_tokens(tokens)
115-
end
116-
117-
def client_information
118-
@storage.client_information
119-
end
120-
121-
def save_client_information(info)
122-
@storage.save_client_information(info)
123-
end
124-
125-
def clear_tokens!
126-
@storage.save_tokens(nil)
109+
# Identifies the OAuth flow this provider drives.
110+
# `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
111+
# which is protocol metadata for the authorization server, not an SDK control signal.
112+
def authorization_flow
113+
:authorization_code
127114
end
128115
end
129116
end

0 commit comments

Comments
 (0)