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 +4 -4
- data/README.md +117 -3
- data/lib/json_rpc_handler.rb +7 -2
- data/lib/mcp/annotations.rb +2 -0
- 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 +42 -1
- data/lib/mcp/client/oauth/flow.rb +160 -25
- data/lib/mcp/client/oauth/provider.rb +15 -25
- data/lib/mcp/client/oauth/storage_backed_provider.rb +43 -0
- data/lib/mcp/client/oauth.rb +3 -1
- data/lib/mcp/client.rb +49 -20
- data/lib/mcp/configuration.rb +1 -0
- data/lib/mcp/resource.rb +4 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/capabilities.rb +14 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +78 -50
- data/lib/mcp/server.rb +38 -2
- 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: b7ace5af244e82b2df4f3fef449fd0942d3d9be46746d820f8207cfcee87af52
|
|
4
|
+
data.tar.gz: ce4b53defad3ed24646806c2236a8d79c68597c7225fa4fcef20fd2d85031c24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
429
|
-
- For
|
|
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`,
|
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/annotations.rb
CHANGED
|
@@ -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
|
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
|
|
@@ -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}"
|