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 +4 -4
- data/README.md +92 -3
- data/lib/json_rpc_handler.rb +7 -2
- data/lib/mcp/client/http.rb +68 -7
- data/lib/mcp/client/oauth/client_credentials_provider.rb +89 -0
- data/lib/mcp/client/oauth/discovery.rb +55 -1
- data/lib/mcp/client/oauth/flow.rb +138 -9
- data/lib/mcp/client/oauth/provider.rb +42 -27
- data/lib/mcp/client/oauth/storage_backed_provider.rb +43 -0
- data/lib/mcp/client/oauth.rb +3 -1
- data/lib/mcp/client.rb +71 -79
- data/lib/mcp/configuration.rb +1 -0
- data/lib/mcp/resource.rb +6 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +51 -31
- data/lib/mcp/server.rb +27 -1
- data/lib/mcp/tool/input_schema.rb +2 -2
- data/lib/mcp/tool/schema.rb +38 -19
- data/lib/mcp/trace_context.rb +23 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1915f5e50dda558f3ba32d0c946dacafda7c0ee183300cd634632505ab206bd
|
|
4
|
+
data.tar.gz: 9f702e675c56e191effefa6a7a7ac5b4d5f3c0c8bded9284484bf7260b551f3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
429
|
-
- For
|
|
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`,
|
data/lib/json_rpc_handler.rb
CHANGED
|
@@ -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?(
|
|
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
|
-
|
|
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,
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# token endpoint
|
|
97
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
108
|
-
|
|
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
|