mcp 0.18.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,27 +38,29 @@ module MCP
38
38
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
39
39
  end
40
40
 
41
- prm = fetch_protected_resource_metadata(
41
+ prm, authorization_server = locate_authorization_server(
42
42
  server_url: server_url,
43
43
  resource_metadata_url: resource_metadata_url,
44
44
  )
45
- authorization_server = first_authorization_server(prm)
46
- ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
47
45
 
48
46
  # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
49
47
  # both the authorization and token requests. When PRM advertises a `resource`,
50
48
  # it MUST identify the same MCP server we are talking to; otherwise we are
51
49
  # being redirected to credentials minted for a different audience.
52
- resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
50
+ resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
51
+
52
+ as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
53
+
54
+ if provider_authorization_flow == :client_credentials
55
+ return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
56
+ end
53
57
 
54
- as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
55
- ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
56
- ensure_secure_endpoints!(as_metadata)
57
58
  ensure_pkce_supported!(as_metadata)
58
59
 
59
60
  client_info = ensure_client_registered(as_metadata: as_metadata)
60
61
 
61
- effective_scope = resolve_scope(scope: scope, prm: prm)
62
+ effective_scope = resolve_scope(scope: scope, prm: prm || {})
63
+ effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
62
64
  pkce = PKCE.generate
63
65
  state = SecureRandom.urlsafe_base64(32)
64
66
 
@@ -91,21 +93,60 @@ module MCP
91
93
  :authorized
92
94
  end
93
95
 
94
- # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
95
- # Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
96
- # token endpoint, and re-runs the audience / issuer / security checks
97
- # before talking to it.
96
+ # Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
97
+ # the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
98
+ # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
99
+ # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
100
+ # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
101
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
102
+ def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
103
+ client_info = client_credentials_client_info
104
+
105
+ form = { "grant_type" => "client_credentials" }
106
+ effective_scope = resolve_scope(scope: scope, prm: prm)
107
+ form["scope"] = effective_scope if effective_scope
108
+ form["resource"] = resource if resource
109
+
110
+ tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
111
+ @provider.save_tokens(tokens)
112
+ :authorized
113
+ end
114
+
115
+ # Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
116
+ # `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
117
+ # flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
118
+ # The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
119
+ # to dynamic registration.
120
+ def client_credentials_client_info
121
+ info = @provider.client_information
122
+ unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
123
+ raise AuthorizationError,
124
+ "Cannot run the client_credentials grant: the provider has no stored `client_id`."
125
+ end
126
+
127
+ info
128
+ end
129
+
130
+ # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
131
+ # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
132
+ # checks before talking to it.
98
133
  #
99
- # Returns `:refreshed` on success. Raises `AuthorizationError` when
100
- # the provider has no refresh token, no client information, or when
101
- # the token endpoint refuses the refresh request.
134
+ # Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
135
+ # or when the token endpoint refuses the refresh request.
102
136
  # https://www.rfc-editor.org/rfc/rfc6749#section-6
103
137
  def refresh!(server_url:, resource_metadata_url: nil)
104
138
  refresh_token = read_token("refresh_token")
105
139
  raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
106
140
 
107
- client_info = @provider.client_information
108
- unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
141
+ stored_client_info = @provider.client_information
142
+ have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
143
+
144
+ # A CIMD-configured provider stores no `client_information` on purpose
145
+ # (the CIMD URL is re-resolved against the live AS metadata on every flow).
146
+ # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
147
+ have_cimd_url = !@provider.client_id_metadata_document_url.nil?
148
+
149
+ unless have_stored_client_info || have_cimd_url
109
150
  raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
110
151
  end
111
152
 
@@ -113,18 +154,26 @@ module MCP
113
154
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
114
155
  end
115
156
 
116
- prm = fetch_protected_resource_metadata(
157
+ prm, authorization_server = locate_authorization_server(
117
158
  server_url: server_url,
118
159
  resource_metadata_url: resource_metadata_url,
119
160
  )
120
- authorization_server = first_authorization_server(prm)
121
- ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
122
161
 
123
- resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
162
+ resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
124
163
 
125
- as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
126
- ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
127
- ensure_secure_endpoints!(as_metadata)
164
+ as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
165
+
166
+ client_info = if have_stored_client_info
167
+ # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
168
+ # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
169
+ stored_client_info
170
+ elsif as_metadata["client_id_metadata_document_supported"] == true
171
+ { "client_id" => @provider.client_id_metadata_document_url }
172
+ else
173
+ raise AuthorizationError,
174
+ "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
175
+ "`client_id_metadata_document_supported: true`."
176
+ end
128
177
 
129
178
  new_tokens = exchange_refresh_token(
130
179
  as_metadata: as_metadata,
@@ -164,6 +213,87 @@ module MCP
164
213
  fetch_metadata_json(urls, label: "protected resource metadata")
165
214
  end
166
215
 
216
+ # Locates the authorization server for `server_url` and returns `[prm, authorization_server]`.
217
+ #
218
+ # Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in
219
+ # `authorization_servers`.
220
+ #
221
+ # Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
222
+ # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
223
+ # Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
224
+ # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
225
+ def locate_authorization_server(server_url:, resource_metadata_url:)
226
+ prm = begin
227
+ fetch_protected_resource_metadata(
228
+ server_url: server_url,
229
+ resource_metadata_url: resource_metadata_url,
230
+ )
231
+ rescue AuthorizationError
232
+ nil
233
+ end
234
+
235
+ if prm
236
+ authorization_server = first_authorization_server(prm)
237
+ ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
238
+ [prm, authorization_server]
239
+ else
240
+ authorization_base = server_origin!(server_url)
241
+ ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)")
242
+ [nil, authorization_base]
243
+ end
244
+ end
245
+
246
+ # Fetches and validates the authorization server's RFC 8414 metadata.
247
+ #
248
+ # On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
249
+ # On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
250
+ # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
251
+ # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
252
+ # When even the metadata document is absent, the legacy spec's default endpoints are used.
253
+ def authorization_server_metadata(authorization_server:, legacy:)
254
+ metadata = if legacy
255
+ begin
256
+ fetch_authorization_server_metadata(issuer_url: authorization_server)
257
+ rescue AuthorizationError
258
+ default_legacy_metadata(authorization_server)
259
+ end
260
+ else
261
+ fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched|
262
+ ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
263
+ end
264
+ end
265
+
266
+ ensure_secure_endpoints!(metadata)
267
+ metadata
268
+ end
269
+
270
+ # The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths
271
+ # relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata
272
+ # to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too).
273
+ def default_legacy_metadata(authorization_base)
274
+ {
275
+ "issuer" => authorization_base,
276
+ "authorization_endpoint" => "#{authorization_base}/authorize",
277
+ "token_endpoint" => "#{authorization_base}/token",
278
+ "registration_endpoint" => "#{authorization_base}/register",
279
+ "code_challenge_methods_supported" => ["S256"],
280
+ }
281
+ end
282
+
283
+ # Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM.
284
+ def server_origin!(server_url)
285
+ uri = URI.parse(server_url.to_s)
286
+ unless uri.is_a?(URI::HTTP) && uri.host
287
+ raise AuthorizationError,
288
+ "Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}."
289
+ end
290
+
291
+ port_part = uri.port == uri.default_port ? "" : ":#{uri.port}"
292
+ "#{uri.scheme}://#{uri.host}#{port_part}"
293
+ rescue URI::InvalidURIError => e
294
+ raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}."
295
+ end
296
+
167
297
  def fetch_authorization_server_metadata(issuer_url:)
168
298
  urls = Discovery.authorization_server_metadata_urls(issuer_url)
169
299
  fetch_metadata_json(urls, label: "authorization server metadata")
@@ -285,6 +415,24 @@ module MCP
285
415
  existing = @provider.client_information
286
416
  return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
287
417
 
418
+ # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
419
+ # if the authorization server advertises Client ID Metadata Document support and the provider has
420
+ # a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
421
+ #
422
+ # The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
423
+ # A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
424
+ # otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
425
+ # a server that has not actually adopted it.
426
+ #
427
+ # The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
428
+ # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
429
+ # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
430
+ # and the current `provider.client_id_metadata_document_url`.
431
+ cimd_url = @provider.client_id_metadata_document_url
432
+ if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
433
+ return { "client_id" => cimd_url }
434
+ end
435
+
288
436
  registration_endpoint = as_metadata["registration_endpoint"]
289
437
  unless registration_endpoint
290
438
  raise AuthorizationError,
@@ -292,7 +440,7 @@ module MCP
292
440
  end
293
441
 
294
442
  response = begin
295
- http_post_json(registration_endpoint, @provider.client_metadata)
443
+ http_post_json(registration_endpoint, registration_client_metadata)
296
444
  rescue Faraday::Error => e
297
445
  raise AuthorizationError,
298
446
  "Dynamic client registration failed: #{e.class}: #{e.message}."
@@ -318,6 +466,20 @@ module MCP
318
466
  info
319
467
  end
320
468
 
469
+ # Returns the client metadata to submit on Dynamic Client Registration.
470
+ # Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
471
+ # so the authorization server can apply the matching redirect URI policy.
472
+ # When the user did not set one explicitly, infer `"native"` vs `"web"` from
473
+ # the registered `redirect_uris`; an explicit value always wins.
474
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
475
+ def registration_client_metadata
476
+ metadata = @provider.client_metadata
477
+ return metadata if metadata[:application_type] || metadata["application_type"]
478
+
479
+ redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
480
+ metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
481
+ end
482
+
321
483
  # Reads `key` from a `client_information` hash that may use either string or
322
484
  # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
323
485
  # `{ client_id:, client_secret: }` and have both work.
@@ -403,6 +565,60 @@ module MCP
403
565
  nil
404
566
  end
405
567
 
568
+ # Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
569
+ #
570
+ # - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
571
+ # `refresh_token` in its registered `grant_types`) MAY request `offline_access`
572
+ # when the authorization server advertises it in metadata `scopes_supported`.
573
+ # When the server advertises it and the client opted in, add it if absent.
574
+ #
575
+ # - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
576
+ # strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
577
+ # PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
578
+ # request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
579
+ # or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
580
+ # the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
581
+ # own request consistent with the AS's advertisement.
582
+ #
583
+ # Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
584
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
585
+ def normalize_offline_access_scope(scope, as_metadata:)
586
+ scopes = scope.to_s.split
587
+
588
+ if server_supports_offline_access?(as_metadata)
589
+ scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
590
+ else
591
+ scopes.delete("offline_access")
592
+ end
593
+
594
+ scopes.empty? ? nil : scopes.join(" ")
595
+ end
596
+
597
+ def server_supports_offline_access?(as_metadata)
598
+ supported = as_metadata["scopes_supported"]
599
+
600
+ supported.is_a?(Array) && supported.include?("offline_access")
601
+ end
602
+
603
+ def wants_refresh_token?
604
+ metadata = @provider.client_metadata
605
+ grant_types = metadata[:grant_types] || metadata["grant_types"]
606
+
607
+ Array(grant_types).include?("refresh_token")
608
+ end
609
+
610
+ # The OAuth flow the provider drives. Dispatching on the provider's
611
+ # declared flow keeps `Flow` from second-guessing intent by parsing
612
+ # `client_metadata[:grant_types]` (which is protocol metadata for the
613
+ # authorization server, not an SDK control signal). A provider that
614
+ # predates this method is treated as the interactive authorization-code
615
+ # flow it was the only option for.
616
+ def provider_authorization_flow
617
+ return :authorization_code unless @provider.respond_to?(:authorization_flow)
618
+
619
+ @provider.authorization_flow
620
+ end
621
+
406
622
  def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
407
623
  authorization_endpoint = as_metadata["authorization_endpoint"]
408
624
  unless authorization_endpoint
@@ -3,14 +3,19 @@
3
3
  module MCP
4
4
  class Client
5
5
  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.
9
11
  #
10
12
  # Required keyword arguments:
11
13
  # - `client_metadata` - Hash sent to the authorization server's Dynamic Client
12
14
  # Registration endpoint. Must include at minimum `redirect_uris`,
13
15
  # `grant_types`, `response_types`, and `token_endpoint_auth_method`.
16
+ # When `application_type` is omitted, the SDK infers `"native"` or `"web"`
17
+ # from `redirect_uris` per SEP-837 before registering; an explicit value
18
+ # always wins.
14
19
  # - `redirect_uri` - String: the redirect URI used for the authorization
15
20
  # request. Must be one of `redirect_uris` in `client_metadata`.
16
21
  # - `redirect_handler` - Callable invoked with the fully-built authorization
@@ -25,7 +30,19 @@ module MCP
25
30
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
26
31
  # `client_information`, and `save_client_information(info)`. Defaults to
27
32
  # an `InMemoryStorage`.
33
+ # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
34
+ # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
35
+ # When the authorization server advertises `client_id_metadata_document_supported: true`,
36
+ # the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
37
+ # Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
38
+ # The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
39
+ # but different encodings of the same query would yield different `client_id` strings for the same document).
40
+ # The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
41
+ # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
42
+ # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
28
43
  class Provider
44
+ include StorageBackedProvider
45
+
29
46
  # Raised when `Provider#initialize` is called with a `redirect_uri` that
30
47
  # is neither HTTPS nor a loopback `http://` URL, per the MCP
31
48
  # authorization spec's Communication Security requirement.
@@ -38,12 +55,21 @@ module MCP
38
55
  # runtime; failing at construction surfaces the bug earlier.
39
56
  class UnregisteredRedirectURIError < ArgumentError; end
40
57
 
58
+ # Raised when `client_id_metadata_document_url` is provided but does not meet
59
+ # the structural requirements for a Client ID Metadata Document URL:
60
+ # HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
61
+ # The CIMD URL is sent to the authorization server as the OAuth `client_id`,
62
+ # so the same Communication Security guarantee that protects the redirect URI
63
+ # applies and the value must unambiguously identify the document.
64
+ class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
65
+
41
66
  attr_reader :client_metadata,
42
67
  :redirect_uri,
43
68
  :scope,
44
69
  :storage,
45
70
  :redirect_handler,
46
- :callback_handler
71
+ :callback_handler,
72
+ :client_id_metadata_document_url
47
73
 
48
74
  def initialize(
49
75
  client_metadata:,
@@ -51,7 +77,8 @@ module MCP
51
77
  redirect_handler:,
52
78
  callback_handler:,
53
79
  scope: nil,
54
- storage: nil
80
+ storage: nil,
81
+ client_id_metadata_document_url: nil
55
82
  )
56
83
  unless Discovery.secure_url?(redirect_uri)
57
84
  raise InsecureRedirectURIError,
@@ -66,36 +93,27 @@ module MCP
66
93
  "(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
67
94
  end
68
95
 
96
+ if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
97
+ raise InvalidClientIDMetadataDocumentURLError,
98
+ "client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
99
+ "with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
100
+ "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
101
+ end
102
+
69
103
  @client_metadata = client_metadata
70
104
  @redirect_uri = redirect_uri
71
105
  @redirect_handler = redirect_handler
72
106
  @callback_handler = callback_handler
73
107
  @scope = scope
74
108
  @storage = storage || InMemoryStorage.new
109
+ @client_id_metadata_document_url = client_id_metadata_document_url
75
110
  end
76
111
 
77
- def access_token
78
- tokens&.dig("access_token") || tokens&.dig(:access_token)
79
- end
80
-
81
- def tokens
82
- @storage.tokens
83
- end
84
-
85
- def save_tokens(tokens)
86
- @storage.save_tokens(tokens)
87
- end
88
-
89
- def client_information
90
- @storage.client_information
91
- end
92
-
93
- def save_client_information(info)
94
- @storage.save_client_information(info)
95
- end
96
-
97
- def clear_tokens!
98
- @storage.save_tokens(nil)
112
+ # Identifies the OAuth flow this provider drives.
113
+ # `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
114
+ # which is protocol metadata for the authorization server, not an SDK control signal.
115
+ def authorization_flow
116
+ :authorization_code
99
117
  end
100
118
  end
101
119
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # Shared token/credential persistence for the OAuth provider classes
7
+ # (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
8
+ # for the client_credentials flow). The two grants differ in how they authenticate,
9
+ # but both read and write the same two pieces of state through a `storage` object:
10
+ # the token response and the client information. This module supplies that delegation
11
+ # so the `Flow` orchestrator can treat any provider uniformly.
12
+ #
13
+ # Including classes must set `@storage` to an object responding to `tokens`,
14
+ # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
15
+ # (see `InMemoryStorage`).
16
+ module StorageBackedProvider
17
+ def access_token
18
+ tokens&.dig("access_token") || tokens&.dig(:access_token)
19
+ end
20
+
21
+ def tokens
22
+ @storage.tokens
23
+ end
24
+
25
+ def save_tokens(tokens)
26
+ @storage.save_tokens(tokens)
27
+ end
28
+
29
+ def client_information
30
+ @storage.client_information
31
+ end
32
+
33
+ def save_client_information(info)
34
+ @storage.save_client_information(info)
35
+ end
36
+
37
+ def clear_tokens!
38
+ @storage.save_tokens(nil)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,13 +4,15 @@ require_relative "oauth/discovery"
4
4
  require_relative "oauth/flow"
5
5
  require_relative "oauth/in_memory_storage"
6
6
  require_relative "oauth/pkce"
7
+ require_relative "oauth/storage_backed_provider"
7
8
  require_relative "oauth/provider"
9
+ require_relative "oauth/client_credentials_provider"
8
10
 
9
11
  module MCP
10
12
  class Client
11
13
  # OAuth client support for the MCP Authorization spec (PRM discovery,
12
14
  # 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).
14
16
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
15
17
  module OAuth
16
18
  end