mcp 0.18.0 → 0.20.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7268234a54e4c0b422a916aabc08de04a4cde157b9a6b3909274ab4629885137
4
- data.tar.gz: 1f48ca777ef0269c8c1f946a23efed1cd990e9ac1898568d8143d84e6c8d7fcb
3
+ metadata.gz: f1915f5e50dda558f3ba32d0c946dacafda7c0ee183300cd634632505ab206bd
4
+ data.tar.gz: 9f702e675c56e191effefa6a7a7ac5b4d5f3c0c8bded9284484bf7260b551f3b
5
5
  SHA512:
6
- metadata.gz: bb859fc7e68f54f1a1d7c3d523a3487799c7b5ff8e3ddb5c01cd84101be79c9b5f81eeeb65b8fb06d620b87898f6c3466492e57ad921eac729c113cd5a609f8d
7
- data.tar.gz: 0bf7b67aa29e8211c97168a9e9472e5fdfb1ea5a97db16d917ab1602f584db6076854e354abe0456384c70e712a797832c0949f12d5a5e76bea6761fcecab0c1
6
+ metadata.gz: 76797292c1345c02d77b63b5d840bf9a39fe719e2df6a232a32a342d9e6974e296bc4e1a08939bba539ea56f9a00ab25cf225936ecd02cd1cee31fdfd7e39d48
7
+ data.tar.gz: f1063ce3c1781e713f556489b0a70d58d102b42d6f220b63a138b97a0532dbfd521b7aea428ed2811d94923bff39ab6f02cad0504ad0597c53e48676cb27984f
data/README.md CHANGED
@@ -47,6 +47,7 @@ It implements the Model Context Protocol specification, handling model context r
47
47
 
48
48
  - `initialize` - Initializes the protocol and returns server capabilities
49
49
  - `ping` - Simple health check
50
+ - `logging/setLevel` - Configures the minimum log level for the server
50
51
  - `tools/list` - Lists all registered tools and their schemas
51
52
  - `tools/call` - Invokes a specific tool with provided arguments
52
53
  - `prompts/list` - Lists all registered prompts and their schemas
@@ -260,6 +261,11 @@ See the relevant sections below for the arguments they receive.
260
261
 
261
262
  The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`.
262
263
 
264
+ > [!NOTE]
265
+ > `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized).
266
+ > If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it
267
+ > under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`.
268
+
263
269
  **Access Pattern:**
264
270
 
265
271
  When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`:
@@ -300,6 +306,36 @@ end
300
306
  }
301
307
  ```
302
308
 
309
+ **Distributed Tracing (W3C Trace Context):**
310
+
311
+ Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating
312
+ [W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through
313
+ incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`,
314
+ `TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values
315
+ to your tracing system yourself:
316
+
317
+ ```ruby
318
+ class TracedTool < MCP::Tool
319
+ def self.call(message:, server_context:)
320
+ traceparent = server_context.dig(:_meta, :traceparent)
321
+ # Hand traceparent/tracestate/baggage to your tracing library
322
+ # (e.g. the opentelemetry-ruby gems) to continue the caller's trace.
323
+
324
+ MCP::Tool::Response.new([{ type: "text", text: "ok" }])
325
+ end
326
+ end
327
+ ```
328
+
329
+ On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods)
330
+ accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request:
331
+
332
+ ```ruby
333
+ meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }
334
+
335
+ client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta)
336
+ client.read_resource(uri: "file:///report.txt", meta: meta)
337
+ ```
338
+
303
339
  #### Configuration Block Data
304
340
 
305
341
  ##### Exception Reporter
@@ -423,10 +459,10 @@ The exception reporter receives two arguments:
423
459
  - `exception`: The Ruby exception object that was raised
424
460
  - `server_context`: A hash containing contextual information about where the error occurred
425
461
 
426
- The server_context hash includes:
462
+ The `server_context` hash includes:
427
463
 
428
- - For tool calls: `{ tool_name: "name", arguments: { ... } }`
429
- - For general request handling: `{ request: { ... } }`
464
+ - For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash)
465
+ - For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name)
430
466
 
431
467
  When an exception occurs:
432
468
 
@@ -891,6 +927,19 @@ end
891
927
 
892
928
  otherwise `resources/read` requests will be a no-op.
893
929
 
930
+ For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler.
931
+ Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`)
932
+ carrying the requested URI in the error `data` member:
933
+
934
+ ```ruby
935
+ server.resources_read_handler do |params|
936
+ resource = lookup(params[:uri])
937
+ raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource
938
+
939
+ [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }]
940
+ end
941
+ ```
942
+
894
943
  ### Resource Templates
895
944
 
896
945
  The `MCP::ResourceTemplate` class provides a way to register resource templates with the server.
@@ -1913,6 +1962,12 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
1913
1962
  - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
1914
1963
  perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
1915
1964
  - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
1965
+ - On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
1966
+ run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
1967
+ The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
1968
+ - Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
1969
+ `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
1970
+ `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
1916
1971
 
1917
1972
  ```ruby
1918
1973
  require "mcp"
@@ -1958,6 +2013,17 @@ Optional keyword arguments:
1958
2013
  - `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
1959
2014
  - `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
1960
2015
  which keeps credentials in process memory only.
2016
+ - `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
2017
+ (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
2018
+ When the authorization server advertises `client_id_metadata_document_supported: true`,
2019
+ the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
2020
+ Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
2021
+ userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
2022
+ them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
2023
+ Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
2024
+ served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
2025
+ the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
2026
+ `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
1961
2027
 
1962
2028
  To persist credentials across restarts, supply your own storage:
1963
2029
 
@@ -2000,6 +2066,29 @@ provider = MCP::Client::OAuth::Provider.new(
2000
2066
  )
2001
2067
  ```
2002
2068
 
2069
+ ##### Client Credentials Grant
2070
+
2071
+ For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
2072
+ The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
2073
+ the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
2074
+
2075
+ ```ruby
2076
+ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
2077
+ client_id: "my-service",
2078
+ client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
2079
+ # token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post"
2080
+ # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
2081
+ )
2082
+
2083
+ transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
2084
+ ```
2085
+
2086
+ Keyword arguments:
2087
+
2088
+ - `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory.
2089
+ - `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
2090
+ - `scope`, `storage`: Optional, same meaning as on `Provider`.
2091
+
2003
2092
  ##### Communication Security
2004
2093
 
2005
2094
  When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
@@ -73,13 +73,18 @@ module JsonRpcHandler
73
73
 
74
74
  error = if !valid_version?(request[:jsonrpc])
75
75
  "JSON-RPC version must be 2.0"
76
- elsif !valid_id?(request[:id], id_validation_pattern)
76
+ elsif !valid_id?(id, id_validation_pattern)
77
77
  "Request ID must match validation pattern, or be an integer or null"
78
78
  elsif !valid_method_name?(request[:method])
79
79
  'Method name must be a string and not start with "rpc."'
80
80
  end
81
81
 
82
- return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
82
+ # Per JSON-RPC 2.0 (Response object, `id`), the error response must carry
83
+ # the same id as the request when the id could be detected; null is only
84
+ # for requests whose id could not be determined. `error_response` nils out
85
+ # ids that fail validation, and the `:unknown_id` sentinel keeps a response
86
+ # (with a null id) being emitted when the request carried no id at all.
87
+ return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
83
88
  code: ErrorCode::INVALID_REQUEST,
84
89
  message: "Invalid Request",
85
90
  data: error,
@@ -185,6 +185,7 @@ module MCP
185
185
  method = request[:method] || request["method"]
186
186
  params = request[:params] || request["params"]
187
187
  oauth_retried = false
188
+ step_up_retried = false
188
189
 
189
190
  begin
190
191
  response = client.post("", request, session_headers)
@@ -217,6 +218,19 @@ module MCP
217
218
  original_error: e,
218
219
  )
219
220
  rescue Faraday::ForbiddenError => e
221
+ # OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in
222
+ # the Bearer challenge means the existing access token is valid
223
+ # but lacks scopes the server now requires for this operation.
224
+ # Re-run the full authorization flow with the escalated scope from
225
+ # the challenge and retry once. A plain 403 without the challenge is
226
+ # surfaced unchanged.
227
+ if @oauth && !step_up_retried && insufficient_scope_challenge?(e)
228
+ step_up_retried = true
229
+ run_step_up_flow!(forbidden_error: e)
230
+
231
+ retry
232
+ end
233
+
220
234
  raise RequestHandlerError.new(
221
235
  "You are forbidden to make #{method} requests",
222
236
  { method: method, params: params },
@@ -337,16 +351,63 @@ module MCP
337
351
  #
338
352
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
339
353
  def run_oauth_flow!(unauthorized_error:)
340
- response = unauthorized_error.response || {}
341
- response_headers = response[:headers] || {}
342
- www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
343
- params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
354
+ params = parse_www_authenticate_from_error(unauthorized_error)
355
+ flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
356
+ return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
344
357
 
358
+ run_full_authorization_flow!(flow: flow, params: params)
359
+ end
360
+
361
+ # Drives a full Authorization Code + PKCE flow without first attempting
362
+ # to refresh the access token. Used for the MCP scope-selection-strategy
363
+ # step-up path: the provider already holds a valid access token,
364
+ # but the server returned a 403 with
365
+ # `WWW-Authenticate: ... error="insufficient_scope", scope="..."`
366
+ # per RFC 6750 Section 3.1. Refreshing the existing token would re-issue
367
+ # the same scope set the server already rejected, so the SDK must run
368
+ # a fresh authorization request. The request asks for the union of
369
+ # the currently granted scope and the newly demanded scope; otherwise
370
+ # the caller would lose previously held scopes and trigger another step-up
371
+ # on the next operation that needs them.
372
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy
373
+ def run_step_up_flow!(forbidden_error:)
374
+ params = parse_www_authenticate_from_error(forbidden_error)
345
375
  flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
346
- if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
347
- return
348
- end
376
+ params = params.merge("scope" => escalated_step_up_scope(params["scope"]))
377
+
378
+ run_full_authorization_flow!(flow: flow, params: params)
379
+ end
380
+
381
+ # Returns the space-separated union of the currently granted scope (read
382
+ # from the stored token response per RFC 6749 Section 5.1) and the scope
383
+ # demanded by the step-up challenge. Duplicates are collapsed; order
384
+ # follows first appearance so existing scopes precede the newly added
385
+ # ones. Returns `nil` when neither side carries a scope so
386
+ # `build_authorization_url` omits the `scope` parameter entirely.
387
+ def escalated_step_up_scope(challenge_scope)
388
+ tokens = @oauth.tokens
389
+ granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil
390
+ scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq
391
+
392
+ scopes.empty? ? nil : scopes.join(" ")
393
+ end
394
+
395
+ # True when the response on `forbidden_error` carries a Bearer challenge
396
+ # with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP
397
+ # scope-selection-strategy section. A 403 without that signal is not a
398
+ # step-up challenge and must not trigger re-authorization.
399
+ def insufficient_scope_challenge?(forbidden_error)
400
+ parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope"
401
+ end
402
+
403
+ def parse_www_authenticate_from_error(error)
404
+ response = error.response || {}
405
+ response_headers = response[:headers] || {}
406
+ header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
407
+ MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
408
+ end
349
409
 
410
+ def run_full_authorization_flow!(flow:, params:)
350
411
  # Use the URL snapshotted at `initialize` time so a post-construction
351
412
  # mutation of `@url` cannot redirect PRM/AS discovery and the authorize
352
413
  # URL to an attacker-controlled host.
@@ -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
@@ -90,7 +90,17 @@ module MCP
90
90
  end
91
91
 
92
92
  # Returns the candidate Authorization Server metadata URLs to probe, in priority order.
93
- # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
93
+ #
94
+ # Per SEP-2351, MCP uses the default `oauth-authorization-server` well-known URI suffix
95
+ # registered by RFC 8414 Section 7.3 and defines no application-specific suffix of its own.
96
+ # The OAuth candidates below therefore use only that default suffix
97
+ # (plus the `openid-configuration` suffix from OpenID Connect Discovery),
98
+ # both in the RFC 8414 Section 3.1 path-inserted form for issuers with a path component
99
+ # and in the root form for issuers without one.
100
+ #
101
+ # - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
102
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351
103
+ # - https://www.rfc-editor.org/rfc/rfc8414#section-3.1
94
104
  def authorization_server_metadata_urls(issuer_url)
95
105
  uri = URI.parse(issuer_url)
96
106
  path = uri.path == "/" ? "" : uri.path.to_s
@@ -183,6 +193,50 @@ module MCP
183
193
  false
184
194
  end
185
195
 
196
+ # Returns true when `url` satisfies the structural requirements for
197
+ # a Client ID Metadata Document URL per the MCP 2025-11-25
198
+ # authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
199
+ #
200
+ # Spec-required:
201
+ #
202
+ # - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply:
203
+ # the document URL is sent verbatim to the authorization server as the OAuth `client_id`
204
+ # and travels off-loopback)
205
+ # - host MUST be present
206
+ # - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource,
207
+ # not the origin
208
+ # - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo
209
+ # would leak credentials into every `client_id` log line
210
+ # - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with
211
+ # the same effective path do not produce different `client_id` strings
212
+ #
213
+ # SDK policy (stricter than the draft):
214
+ #
215
+ # - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include,
216
+ # but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct
217
+ # `client_id` values for the same logical document.
218
+ def client_id_metadata_document_url?(url)
219
+ return false if url.nil? || url.to_s.empty?
220
+
221
+ uri = URI.parse(url.to_s)
222
+ return false unless uri.scheme&.downcase == "https"
223
+ return false if uri.host.nil? || uri.host.empty?
224
+ return false unless uri.fragment.nil?
225
+ return false unless uri.query.nil?
226
+ return false if uri.respond_to?(:user) && (uri.user || uri.password)
227
+
228
+ path = uri.path.to_s
229
+ return false if path.empty? || path == "/"
230
+
231
+ decoded = path.gsub(/%2[eE]/, ".")
232
+ segments = decoded.split("/", -1)
233
+ return false if segments.any? { |segment| segment == "." || segment == ".." }
234
+
235
+ true
236
+ rescue URI::InvalidURIError
237
+ false
238
+ end
239
+
186
240
  # Like `canonicalize_url` but also strips query string, fragment, and
187
241
  # userinfo. This variant is used for identity comparison against
188
242
  # the request URL Faraday actually sends, which differs from the value
@@ -54,11 +54,17 @@ module MCP
54
54
  as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
55
55
  ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
56
56
  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
+
57
62
  ensure_pkce_supported!(as_metadata)
58
63
 
59
64
  client_info = ensure_client_registered(as_metadata: as_metadata)
60
65
 
61
66
  effective_scope = resolve_scope(scope: scope, prm: prm)
67
+ effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
62
68
  pkce = PKCE.generate
63
69
  state = SecureRandom.urlsafe_base64(32)
64
70
 
@@ -91,21 +97,60 @@ module MCP
91
97
  :authorized
92
98
  end
93
99
 
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.
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.
98
137
  #
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.
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.
102
140
  # https://www.rfc-editor.org/rfc/rfc6749#section-6
103
141
  def refresh!(server_url:, resource_metadata_url: nil)
104
142
  refresh_token = read_token("refresh_token")
105
143
  raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
106
144
 
107
- client_info = @provider.client_information
108
- unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
145
+ stored_client_info = @provider.client_information
146
+ have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
147
+
148
+ # A CIMD-configured provider stores no `client_information` on purpose
149
+ # (the CIMD URL is re-resolved against the live AS metadata on every flow).
150
+ # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
151
+ have_cimd_url = !@provider.client_id_metadata_document_url.nil?
152
+
153
+ unless have_stored_client_info || have_cimd_url
109
154
  raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
110
155
  end
111
156
 
@@ -126,6 +171,18 @@ module MCP
126
171
  ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
127
172
  ensure_secure_endpoints!(as_metadata)
128
173
 
174
+ client_info = if have_stored_client_info
175
+ # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
176
+ # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
177
+ stored_client_info
178
+ elsif as_metadata["client_id_metadata_document_supported"] == true
179
+ { "client_id" => @provider.client_id_metadata_document_url }
180
+ else
181
+ raise AuthorizationError,
182
+ "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
183
+ "`client_id_metadata_document_supported: true`."
184
+ end
185
+
129
186
  new_tokens = exchange_refresh_token(
130
187
  as_metadata: as_metadata,
131
188
  client_info: client_info,
@@ -285,6 +342,24 @@ module MCP
285
342
  existing = @provider.client_information
286
343
  return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
287
344
 
345
+ # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
346
+ # if the authorization server advertises Client ID Metadata Document support and the provider has
347
+ # a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
348
+ #
349
+ # The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
350
+ # A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
351
+ # otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
352
+ # a server that has not actually adopted it.
353
+ #
354
+ # The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
355
+ # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
356
+ # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
357
+ # and the current `provider.client_id_metadata_document_url`.
358
+ cimd_url = @provider.client_id_metadata_document_url
359
+ if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
360
+ return { "client_id" => cimd_url }
361
+ end
362
+
288
363
  registration_endpoint = as_metadata["registration_endpoint"]
289
364
  unless registration_endpoint
290
365
  raise AuthorizationError,
@@ -403,6 +478,60 @@ module MCP
403
478
  nil
404
479
  end
405
480
 
481
+ # Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
482
+ #
483
+ # - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
484
+ # `refresh_token` in its registered `grant_types`) MAY request `offline_access`
485
+ # when the authorization server advertises it in metadata `scopes_supported`.
486
+ # When the server advertises it and the client opted in, add it if absent.
487
+ #
488
+ # - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
489
+ # strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
490
+ # PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
491
+ # request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
492
+ # or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
493
+ # the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
494
+ # own request consistent with the AS's advertisement.
495
+ #
496
+ # Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
497
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
498
+ def normalize_offline_access_scope(scope, as_metadata:)
499
+ scopes = scope.to_s.split
500
+
501
+ if server_supports_offline_access?(as_metadata)
502
+ scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
503
+ else
504
+ scopes.delete("offline_access")
505
+ end
506
+
507
+ scopes.empty? ? nil : scopes.join(" ")
508
+ end
509
+
510
+ def server_supports_offline_access?(as_metadata)
511
+ supported = as_metadata["scopes_supported"]
512
+
513
+ supported.is_a?(Array) && supported.include?("offline_access")
514
+ end
515
+
516
+ def wants_refresh_token?
517
+ metadata = @provider.client_metadata
518
+ grant_types = metadata[:grant_types] || metadata["grant_types"]
519
+
520
+ Array(grant_types).include?("refresh_token")
521
+ end
522
+
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
+
406
535
  def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
407
536
  authorization_endpoint = as_metadata["authorization_endpoint"]
408
537
  unless authorization_endpoint