mcp 0.19.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 959e38a26541c9681c11e8bc227e84c42d20e3d98de1568c0afd45c80f3b4474
4
- data.tar.gz: e2bf6b0c7926e1803f9dff1279c1f5a35d96f1cf8a1e3c6141b22a97871c8ec2
3
+ metadata.gz: b7ace5af244e82b2df4f3fef449fd0942d3d9be46746d820f8207cfcee87af52
4
+ data.tar.gz: ce4b53defad3ed24646806c2236a8d79c68597c7225fa4fcef20fd2d85031c24
5
5
  SHA512:
6
- metadata.gz: 5f347a7259f4e728df5edad217d6963cb3428188e46244efd9be9908e84bf4a7134600c6f453249eedc87e33f998f3f51811922659f116811251a64d78f1bbee
7
- data.tar.gz: 3d8d25f65c590d07157db74d87423bbde82250b5d99cf2e3182c8adbc198f534f96809265c8615160f49f963a3b2dc2a07a85db2f6f2cc83e11b068583f405de
6
+ metadata.gz: 2c2ef2a03fb1b989e691aea55d350f06b9f1f357b0acfcda4e227ca359c3a12990d06fe280ced39b1e6d620181aa9c7b8d355d5985c70d090512636da7c34f3a
7
+ data.tar.gz: a75f884057bc318a98943fec8967c4725c63dd767552dc52591c8e08143474f8a28907e929cdbe0efb645426766b75314449b9df8c3c86aa90bb0a68247004ff
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
@@ -230,6 +231,31 @@ server = MCP::Server.new(
230
231
  )
231
232
  ```
232
233
 
234
+ ### Capability Extensions
235
+
236
+ Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
237
+ Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
238
+ values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
239
+
240
+ On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
241
+
242
+ ```ruby
243
+ capabilities = MCP::Server::Capabilities.new
244
+ capabilities.support_tools
245
+ capabilities.support_extensions("com.example/feature" => { enabled: true })
246
+
247
+ server = MCP::Server.new(name: "my_server", capabilities: capabilities)
248
+ ```
249
+
250
+ The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
251
+ readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
252
+
253
+ On the client, pass extensions through `connect`:
254
+
255
+ ```ruby
256
+ client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
257
+ ```
258
+
233
259
  ### Server Context and Configuration Block Data
234
260
 
235
261
  #### `server_context`
@@ -260,6 +286,11 @@ See the relevant sections below for the arguments they receive.
260
286
 
261
287
  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
288
 
289
+ > [!NOTE]
290
+ > `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized).
291
+ > If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it
292
+ > under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`.
293
+
263
294
  **Access Pattern:**
264
295
 
265
296
  When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`:
@@ -300,6 +331,36 @@ end
300
331
  }
301
332
  ```
302
333
 
334
+ **Distributed Tracing (W3C Trace Context):**
335
+
336
+ Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating
337
+ [W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through
338
+ incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`,
339
+ `TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values
340
+ to your tracing system yourself:
341
+
342
+ ```ruby
343
+ class TracedTool < MCP::Tool
344
+ def self.call(message:, server_context:)
345
+ traceparent = server_context.dig(:_meta, :traceparent)
346
+ # Hand traceparent/tracestate/baggage to your tracing library
347
+ # (e.g. the opentelemetry-ruby gems) to continue the caller's trace.
348
+
349
+ MCP::Tool::Response.new([{ type: "text", text: "ok" }])
350
+ end
351
+ end
352
+ ```
353
+
354
+ On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods)
355
+ accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request:
356
+
357
+ ```ruby
358
+ meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }
359
+
360
+ client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta)
361
+ client.read_resource(uri: "file:///report.txt", meta: meta)
362
+ ```
363
+
303
364
  #### Configuration Block Data
304
365
 
305
366
  ##### Exception Reporter
@@ -423,10 +484,10 @@ The exception reporter receives two arguments:
423
484
  - `exception`: The Ruby exception object that was raised
424
485
  - `server_context`: A hash containing contextual information about where the error occurred
425
486
 
426
- The server_context hash includes:
487
+ The `server_context` hash includes:
427
488
 
428
- - For tool calls: `{ tool_name: "name", arguments: { ... } }`
429
- - For general request handling: `{ request: { ... } }`
489
+ - For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash)
490
+ - For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name)
430
491
 
431
492
  When an exception occurs:
432
493
 
@@ -513,6 +574,10 @@ end
513
574
  The server_context parameter is the server_context passed into the server and can be used to pass per request information,
514
575
  e.g. around authentication state.
515
576
 
577
+ Tool arguments arrive as a `Hash` with symbol keys at every nesting level, because the transports parse JSON with `symbolize_names: true`.
578
+ Read nested objects with symbol keys (`payload[:subject]`, not `payload["subject"]`).
579
+ See [Tool argument keys](docs/building-servers.md#tool-argument-keys) for details and a testing tip.
580
+
516
581
  ### Tool Annotations
517
582
 
518
583
  Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported:
@@ -891,6 +956,19 @@ end
891
956
 
892
957
  otherwise `resources/read` requests will be a no-op.
893
958
 
959
+ For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler.
960
+ Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`)
961
+ carrying the requested URI in the error `data` member:
962
+
963
+ ```ruby
964
+ server.resources_read_handler do |params|
965
+ resource = lookup(params[:uri])
966
+ raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource
967
+
968
+ [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }]
969
+ end
970
+ ```
971
+
894
972
  ### Resource Templates
895
973
 
896
974
  The `MCP::ResourceTemplate` class provides a way to register resource templates with the server.
@@ -1586,6 +1664,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
1586
1664
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
1587
1665
  ```
1588
1666
 
1667
+ In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required,
1668
+ handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server),
1669
+ and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped
1670
+ (there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
1671
+
1589
1672
  You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
1590
1673
  Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
1591
1674
 
@@ -1912,7 +1995,13 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
1912
1995
  - Send `Authorization: Bearer <access_token>` on every request when a token is available.
1913
1996
  - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
1914
1997
  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.
1998
+ - Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
1999
+ as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates),
2000
+ and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
1915
2001
  - 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).
2002
+ - 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),
2003
+ 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.
2004
+ 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.
1916
2005
  - Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
1917
2006
  `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
1918
2007
  `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.
@@ -1952,6 +2041,8 @@ Required keyword arguments to `Provider.new`:
1952
2041
 
1953
2042
  - `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
1954
2043
  `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
2044
+ When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native);
2045
+ an explicit value always wins.
1955
2046
  - `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
1956
2047
  - `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
1957
2048
  - `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.
@@ -2014,6 +2105,29 @@ provider = MCP::Client::OAuth::Provider.new(
2014
2105
  )
2015
2106
  ```
2016
2107
 
2108
+ ##### Client Credentials Grant
2109
+
2110
+ For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
2111
+ The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
2112
+ the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
2113
+
2114
+ ```ruby
2115
+ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
2116
+ client_id: "my-service",
2117
+ client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
2118
+ # token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post"
2119
+ # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
2120
+ )
2121
+
2122
+ transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
2123
+ ```
2124
+
2125
+ Keyword arguments:
2126
+
2127
+ - `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory.
2128
+ - `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
2129
+ - `scope`, `storage`: Optional, same meaning as on `Provider`.
2130
+
2017
2131
  ##### Communication Security
2018
2132
 
2019
2133
  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,
@@ -5,6 +5,8 @@ module MCP
5
5
  attr_reader :audience, :priority, :last_modified
6
6
 
7
7
  def initialize(audience: nil, priority: nil, last_modified: nil)
8
+ raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1)
9
+
8
10
  @audience = audience
9
11
  @priority = priority
10
12
  @last_modified = last_modified
@@ -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
@@ -227,6 +237,23 @@ module MCP
227
237
  false
228
238
  end
229
239
 
240
+ # Infers the OIDC Dynamic Client Registration `application_type` for a client from its `redirect_uris`.
241
+ # Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration
242
+ # so the authorization server can apply the matching redirect URI policy.
243
+ #
244
+ # Returns `"native"` when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1)
245
+ # or an http(s) URI whose host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`, RFC 8252 Section 7.3).
246
+ # Returns `"web"` otherwise, including when `redirect_uris` is nil, empty, or contains an unparseable URI.
247
+ #
248
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
249
+ # - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
250
+ def infer_application_type(redirect_uris)
251
+ uris = Array(redirect_uris)
252
+ return "web" if uris.empty?
253
+
254
+ uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web"
255
+ end
256
+
230
257
  # Like `canonicalize_url` but also strips query string, fragment, and
231
258
  # userinfo. This variant is used for identity comparison against
232
259
  # the request URL Faraday actually sends, which differs from the value
@@ -335,6 +362,20 @@ module MCP
335
362
  nil
336
363
  end
337
364
 
365
+ # A redirect URI counts as native when it uses a custom non-http(s) scheme
366
+ # (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
367
+ # a loopback address. A URI without a scheme or one that fails to parse is not native.
368
+ def native_redirect_uri?(url)
369
+ uri = URI.parse(url.to_s)
370
+ scheme = uri.scheme&.downcase
371
+ return false if scheme.nil?
372
+ return loopback_host?(uri.host) if ["http", "https"].include?(scheme)
373
+
374
+ true
375
+ rescue URI::InvalidURIError
376
+ false
377
+ end
378
+
338
379
  def base_url(uri)
339
380
  port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
340
381
  "#{uri.scheme}://#{uri.host}#{port_part}"