mcp 0.20.0 → 0.22.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 +121 -9
- data/lib/mcp/annotations.rb +7 -0
- data/lib/mcp/client/http.rb +66 -4
- data/lib/mcp/client/oauth/discovery.rb +31 -0
- data/lib/mcp/client/oauth/flow.rb +103 -16
- data/lib/mcp/client/oauth/provider.rb +3 -0
- data/lib/mcp/client/stdio.rb +21 -1
- data/lib/mcp/client.rb +197 -31
- data/lib/mcp/server/capabilities.rb +14 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +27 -19
- data/lib/mcp/server.rb +24 -2
- data/lib/mcp/server_context.rb +7 -2
- data/lib/mcp/tool/output_schema.rb +17 -0
- data/lib/mcp/tool/schema.rb +66 -8
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0e0cdbc945ee9ec5d178aeaedb1c8af84eca0bae8db8e264f523b47b8d67c52
|
|
4
|
+
data.tar.gz: 6acb2d299d9ae215c26db733091ebbcbb3f6b1477c94d9b4047f9ab37c3a8f0c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8299a6cf0101d4b8b980a69ed3128636aae6f0497fb431d14fdc0f6b8b659fb18ab5d886cf1cb567c53f385a4f8c0cdaf5e91aff6d1a4fe4daefca25d17a7dc2
|
|
7
|
+
data.tar.gz: dff4075f2a0fdf27329af3455b1db6960e38f557ea2436388f1a271d6915172e318b9630ccc8312fab16e3e8ac42e0dc84ef20e974b313056d377f3798d4a83c
|
data/README.md
CHANGED
|
@@ -41,7 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
|
|
|
41
41
|
- Supports roots (server-to-client filesystem boundary queries)
|
|
42
42
|
- Supports sampling (server-to-client LLM completion requests)
|
|
43
43
|
- Supports cursor-based pagination for list operations
|
|
44
|
-
- Supports
|
|
44
|
+
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
|
|
45
45
|
|
|
46
46
|
### Supported Methods
|
|
47
47
|
|
|
@@ -231,6 +231,31 @@ server = MCP::Server.new(
|
|
|
231
231
|
)
|
|
232
232
|
```
|
|
233
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
|
+
|
|
234
259
|
### Server Context and Configuration Block Data
|
|
235
260
|
|
|
236
261
|
#### `server_context`
|
|
@@ -549,6 +574,10 @@ end
|
|
|
549
574
|
The server_context parameter is the server_context passed into the server and can be used to pass per request information,
|
|
550
575
|
e.g. around authentication state.
|
|
551
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
|
+
|
|
552
581
|
### Tool Annotations
|
|
553
582
|
|
|
554
583
|
Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported:
|
|
@@ -669,8 +698,28 @@ class WeatherTool < MCP::Tool
|
|
|
669
698
|
end
|
|
670
699
|
```
|
|
671
700
|
|
|
672
|
-
Please note: in this case, you must provide `type: "array"`. The default type
|
|
673
|
-
|
|
701
|
+
Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`,
|
|
702
|
+
applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`).
|
|
703
|
+
|
|
704
|
+
Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root
|
|
705
|
+
(`{ type: "string" }`) or a root-level composition:
|
|
706
|
+
|
|
707
|
+
```ruby
|
|
708
|
+
class FlexibleTool < MCP::Tool
|
|
709
|
+
output_schema(
|
|
710
|
+
oneOf: [
|
|
711
|
+
{ type: "string" },
|
|
712
|
+
{ type: "array", items: { type: "number" } }
|
|
713
|
+
]
|
|
714
|
+
)
|
|
715
|
+
end
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it
|
|
719
|
+
(`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to
|
|
720
|
+
all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are
|
|
721
|
+
capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects;
|
|
722
|
+
violations raise `ArgumentError` at construction time.
|
|
674
723
|
|
|
675
724
|
MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that:
|
|
676
725
|
|
|
@@ -700,6 +749,10 @@ Tools can return structured data alongside text content using the `structured_co
|
|
|
700
749
|
|
|
701
750
|
The structured content will be included in the JSON-RPC response as the `structuredContent` field.
|
|
702
751
|
|
|
752
|
+
Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array)
|
|
753
|
+
without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients
|
|
754
|
+
that only read `content` still receive the data.
|
|
755
|
+
|
|
703
756
|
```ruby
|
|
704
757
|
class WeatherTool < MCP::Tool
|
|
705
758
|
description "Get current weather and return structured data"
|
|
@@ -1176,12 +1229,7 @@ poll it to exit early. When a tool returns after cancellation has been observed,
|
|
|
1176
1229
|
the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
|
|
1177
1230
|
is never cancellable per the spec.
|
|
1178
1231
|
|
|
1179
|
-
|
|
1180
|
-
> Client-initiated cancellation (`Client#cancel` equivalent that would also abort
|
|
1181
|
-
> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
|
|
1182
|
-
> from the client side can be done by constructing the notification payload and writing it
|
|
1183
|
-
> directly through the transport, but the calling thread does not yet unwind automatically.
|
|
1184
|
-
> This is tracked as a follow-up.
|
|
1232
|
+
Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below.
|
|
1185
1233
|
|
|
1186
1234
|
#### Server-Side: Handlers that Check for Cancellation
|
|
1187
1235
|
|
|
@@ -1290,6 +1338,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
|
|
|
1290
1338
|
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
|
|
1291
1339
|
via `server_context.cancelled?` between nested calls.
|
|
1292
1340
|
|
|
1341
|
+
#### Client-Side: Cancelling an In-Flight Request
|
|
1342
|
+
|
|
1343
|
+
`MCP::Client` lets the caller cancel a request it has already issued. The recommended pattern is to pass
|
|
1344
|
+
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
|
|
1345
|
+
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
|
|
1346
|
+
the server, and the calling thread is woken up with `MCP::CancelledError`:
|
|
1347
|
+
|
|
1348
|
+
```ruby
|
|
1349
|
+
client = MCP::Client.new(transport: transport)
|
|
1350
|
+
cancellation = MCP::Cancellation.new
|
|
1351
|
+
|
|
1352
|
+
Thread.new do
|
|
1353
|
+
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
|
1354
|
+
rescue MCP::CancelledError
|
|
1355
|
+
# cleanup
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
# Later, from another thread:
|
|
1359
|
+
cancellation.cancel(reason: "user pressed cancel")
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
|
|
1363
|
+
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword.
|
|
1364
|
+
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
|
|
1365
|
+
|
|
1366
|
+
> [!NOTE]
|
|
1367
|
+
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
|
|
1368
|
+
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
|
|
1369
|
+
> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
|
|
1370
|
+
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
|
|
1371
|
+
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
|
|
1372
|
+
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
|
|
1373
|
+
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
|
|
1374
|
+
|
|
1375
|
+
##### Wire-order guarantees
|
|
1376
|
+
|
|
1377
|
+
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
|
|
1378
|
+
so the server is guaranteed to read the request line before the cancel line.
|
|
1379
|
+
|
|
1380
|
+
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
|
1381
|
+
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
|
|
1382
|
+
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
|
|
1383
|
+
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)),
|
|
1384
|
+
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
|
|
1385
|
+
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
|
|
1386
|
+
|
|
1387
|
+
##### Custom transports
|
|
1388
|
+
|
|
1389
|
+
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
|
|
1390
|
+
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
|
|
1391
|
+
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
|
|
1392
|
+
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
|
|
1393
|
+
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
|
|
1394
|
+
|
|
1293
1395
|
### Ping
|
|
1294
1396
|
|
|
1295
1397
|
The MCP Ruby SDK supports the
|
|
@@ -1635,6 +1737,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
|
|
|
1635
1737
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
|
|
1636
1738
|
```
|
|
1637
1739
|
|
|
1740
|
+
In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required,
|
|
1741
|
+
handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server),
|
|
1742
|
+
and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped
|
|
1743
|
+
(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
|
|
1744
|
+
|
|
1638
1745
|
You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
|
|
1639
1746
|
Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
|
|
1640
1747
|
|
|
@@ -1961,6 +2068,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
|
|
|
1961
2068
|
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
|
|
1962
2069
|
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
|
1963
2070
|
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.
|
|
2071
|
+
- 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
|
|
2072
|
+
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),
|
|
2073
|
+
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
|
|
1964
2074
|
- 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
2075
|
- 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
2076
|
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,6 +2114,8 @@ Required keyword arguments to `Provider.new`:
|
|
|
2004
2114
|
|
|
2005
2115
|
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
|
|
2006
2116
|
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
|
|
2117
|
+
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);
|
|
2118
|
+
an explicit value always wins.
|
|
2007
2119
|
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
|
|
2008
2120
|
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
|
|
2009
2121
|
- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.
|
data/lib/mcp/annotations.rb
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
4
|
class Annotations
|
|
5
|
+
SUPPORTED_AUDIENCES = ["user", "assistant"].freeze
|
|
6
|
+
|
|
5
7
|
attr_reader :audience, :priority, :last_modified
|
|
6
8
|
|
|
7
9
|
def initialize(audience: nil, priority: nil, last_modified: nil)
|
|
10
|
+
if audience && !(audience.is_a?(Array) && audience.all? { |role| SUPPORTED_AUDIENCES.include?(role) })
|
|
11
|
+
raise ArgumentError, 'The value of audience must be an array of "user" or "assistant".'
|
|
12
|
+
end
|
|
13
|
+
raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1)
|
|
14
|
+
|
|
8
15
|
@audience = audience
|
|
9
16
|
@priority = priority
|
|
10
17
|
@last_modified = last_modified
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -16,6 +16,8 @@ module MCP
|
|
|
16
16
|
ACCEPT_HEADER = "application/json, text/event-stream"
|
|
17
17
|
SESSION_ID_HEADER = "Mcp-Session-Id"
|
|
18
18
|
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
|
19
|
+
METHOD_HEADER = "Mcp-Method"
|
|
20
|
+
NAME_HEADER = "Mcp-Name"
|
|
19
21
|
|
|
20
22
|
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
21
23
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
@@ -178,9 +180,18 @@ module MCP
|
|
|
178
180
|
end
|
|
179
181
|
|
|
180
182
|
# Sends a JSON-RPC request and returns the parsed response body.
|
|
181
|
-
# After a successful `initialize` handshake, the session ID and protocol
|
|
182
|
-
#
|
|
183
|
-
#
|
|
183
|
+
# After a successful `initialize` handshake, the session ID and protocol version returned by
|
|
184
|
+
# the server are captured and automatically included on subsequent requests.
|
|
185
|
+
#
|
|
186
|
+
# If a block is given, it is invoked just before Faraday's `post` is called.
|
|
187
|
+
# Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
|
188
|
+
# so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens
|
|
189
|
+
# inside `post`. `MCP::Client#dispatch_with_cancellation` uses this yield to release
|
|
190
|
+
# the cancel-dispatch thread, which then issues a separate `notifications/cancelled` POST
|
|
191
|
+
# that may overlap with the original request on the network. The spec covers this:
|
|
192
|
+
# the sender has issued the request and still believes it in-progress, and receivers MAY ignore
|
|
193
|
+
# a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
|
|
194
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
|
|
184
195
|
def send_request(request:)
|
|
185
196
|
method = request[:method] || request["method"]
|
|
186
197
|
params = request[:params] || request["params"]
|
|
@@ -188,7 +199,8 @@ module MCP
|
|
|
188
199
|
step_up_retried = false
|
|
189
200
|
|
|
190
201
|
begin
|
|
191
|
-
|
|
202
|
+
yield if block_given?
|
|
203
|
+
response = client.post("", request, session_headers.merge(request_metadata_headers(method, params)))
|
|
192
204
|
body = parse_response_body(response, method, params)
|
|
193
205
|
|
|
194
206
|
capture_session_info(method, response, body)
|
|
@@ -274,6 +286,23 @@ module MCP
|
|
|
274
286
|
end
|
|
275
287
|
end
|
|
276
288
|
|
|
289
|
+
# Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
|
|
290
|
+
# `notifications/cancelled` for an in-flight request. The server acknowledges with HTTP 202 Accepted
|
|
291
|
+
# per the Streamable HTTP spec.
|
|
292
|
+
def send_notification(notification:)
|
|
293
|
+
method = notification[:method] || notification["method"]
|
|
294
|
+
|
|
295
|
+
client.post("", notification, session_headers)
|
|
296
|
+
nil
|
|
297
|
+
rescue Faraday::Error => e
|
|
298
|
+
raise RequestHandlerError.new(
|
|
299
|
+
"Failed to send #{method} notification",
|
|
300
|
+
{ method: method },
|
|
301
|
+
error_type: :internal_error,
|
|
302
|
+
original_error: e,
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
277
306
|
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
|
|
278
307
|
# with the current `Mcp-Session-Id` header, and clears locally tracked
|
|
279
308
|
# session state afterward. No-op when no session has been established.
|
|
@@ -341,6 +370,39 @@ module MCP
|
|
|
341
370
|
request_headers
|
|
342
371
|
end
|
|
343
372
|
|
|
373
|
+
# Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries
|
|
374
|
+
# can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name`
|
|
375
|
+
# (tools, prompts) or, when absent, `params.uri` (resources).
|
|
376
|
+
#
|
|
377
|
+
# https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata
|
|
378
|
+
def request_metadata_headers(method, params)
|
|
379
|
+
return {} unless method
|
|
380
|
+
|
|
381
|
+
metadata_headers = { METHOD_HEADER => method }
|
|
382
|
+
if params.is_a?(Hash)
|
|
383
|
+
name = params[:name] || params["name"]
|
|
384
|
+
name = params[:uri] || params["uri"] unless name.is_a?(String)
|
|
385
|
+
metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
metadata_headers
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
|
|
392
|
+
# which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
|
|
393
|
+
# `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
|
|
394
|
+
def encode_header_value(value)
|
|
395
|
+
return value if safe_header_value?(value)
|
|
396
|
+
|
|
397
|
+
"=?base64?#{[value].pack("m0")}?="
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def safe_header_value?(value)
|
|
401
|
+
value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } &&
|
|
402
|
+
(value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) &&
|
|
403
|
+
!(value.start_with?("=?base64?") && value.end_with?("?="))
|
|
404
|
+
end
|
|
405
|
+
|
|
344
406
|
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
|
|
345
407
|
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
|
346
408
|
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
|
@@ -237,6 +237,23 @@ module MCP
|
|
|
237
237
|
false
|
|
238
238
|
end
|
|
239
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
|
+
|
|
240
257
|
# Like `canonicalize_url` but also strips query string, fragment, and
|
|
241
258
|
# userinfo. This variant is used for identity comparison against
|
|
242
259
|
# the request URL Faraday actually sends, which differs from the value
|
|
@@ -345,6 +362,20 @@ module MCP
|
|
|
345
362
|
nil
|
|
346
363
|
end
|
|
347
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
|
+
|
|
348
379
|
def base_url(uri)
|
|
349
380
|
port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
|
|
350
381
|
"#{uri.scheme}://#{uri.host}#{port_part}"
|
|
@@ -38,22 +38,18 @@ module MCP
|
|
|
38
38
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
prm =
|
|
41
|
+
prm, authorization_server = locate_authorization_server(
|
|
42
42
|
server_url: server_url,
|
|
43
43
|
resource_metadata_url: resource_metadata_url,
|
|
44
44
|
)
|
|
45
|
-
authorization_server = first_authorization_server(prm)
|
|
46
|
-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
47
45
|
|
|
48
46
|
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
|
|
49
47
|
# both the authorization and token requests. When PRM advertises a `resource`,
|
|
50
48
|
# it MUST identify the same MCP server we are talking to; otherwise we are
|
|
51
49
|
# being redirected to credentials minted for a different audience.
|
|
52
|
-
resource = canonical_resource(server_url: server_url, prm_resource: prm
|
|
50
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
53
51
|
|
|
54
|
-
as_metadata =
|
|
55
|
-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
56
|
-
ensure_secure_endpoints!(as_metadata)
|
|
52
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
57
53
|
|
|
58
54
|
if provider_authorization_flow == :client_credentials
|
|
59
55
|
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
@@ -63,7 +59,7 @@ module MCP
|
|
|
63
59
|
|
|
64
60
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
65
61
|
|
|
66
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
62
|
+
effective_scope = resolve_scope(scope: scope, prm: prm || {})
|
|
67
63
|
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
68
64
|
pkce = PKCE.generate
|
|
69
65
|
state = SecureRandom.urlsafe_base64(32)
|
|
@@ -158,18 +154,14 @@ module MCP
|
|
|
158
154
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
159
155
|
end
|
|
160
156
|
|
|
161
|
-
prm =
|
|
157
|
+
prm, authorization_server = locate_authorization_server(
|
|
162
158
|
server_url: server_url,
|
|
163
159
|
resource_metadata_url: resource_metadata_url,
|
|
164
160
|
)
|
|
165
|
-
authorization_server = first_authorization_server(prm)
|
|
166
|
-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
167
161
|
|
|
168
|
-
resource = canonical_resource(server_url: server_url, prm_resource: prm
|
|
162
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
169
163
|
|
|
170
|
-
as_metadata =
|
|
171
|
-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
172
|
-
ensure_secure_endpoints!(as_metadata)
|
|
164
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
173
165
|
|
|
174
166
|
client_info = if have_stored_client_info
|
|
175
167
|
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
@@ -221,6 +213,87 @@ module MCP
|
|
|
221
213
|
fetch_metadata_json(urls, label: "protected resource metadata")
|
|
222
214
|
end
|
|
223
215
|
|
|
216
|
+
# Locates the authorization server for `server_url` and returns `[prm, authorization_server]`.
|
|
217
|
+
#
|
|
218
|
+
# Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in
|
|
219
|
+
# `authorization_servers`.
|
|
220
|
+
#
|
|
221
|
+
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
|
|
222
|
+
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
|
|
223
|
+
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
|
|
224
|
+
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
|
225
|
+
def locate_authorization_server(server_url:, resource_metadata_url:)
|
|
226
|
+
prm = begin
|
|
227
|
+
fetch_protected_resource_metadata(
|
|
228
|
+
server_url: server_url,
|
|
229
|
+
resource_metadata_url: resource_metadata_url,
|
|
230
|
+
)
|
|
231
|
+
rescue AuthorizationError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if prm
|
|
236
|
+
authorization_server = first_authorization_server(prm)
|
|
237
|
+
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
238
|
+
[prm, authorization_server]
|
|
239
|
+
else
|
|
240
|
+
authorization_base = server_origin!(server_url)
|
|
241
|
+
ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)")
|
|
242
|
+
[nil, authorization_base]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Fetches and validates the authorization server's RFC 8414 metadata.
|
|
247
|
+
#
|
|
248
|
+
# On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
|
|
249
|
+
# On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
|
|
250
|
+
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
|
|
251
|
+
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
|
|
252
|
+
# When even the metadata document is absent, the legacy spec's default endpoints are used.
|
|
253
|
+
def authorization_server_metadata(authorization_server:, legacy:)
|
|
254
|
+
metadata = if legacy
|
|
255
|
+
begin
|
|
256
|
+
fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
257
|
+
rescue AuthorizationError
|
|
258
|
+
default_legacy_metadata(authorization_server)
|
|
259
|
+
end
|
|
260
|
+
else
|
|
261
|
+
fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched|
|
|
262
|
+
ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
ensure_secure_endpoints!(metadata)
|
|
267
|
+
metadata
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths
|
|
271
|
+
# relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata
|
|
272
|
+
# to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too).
|
|
273
|
+
def default_legacy_metadata(authorization_base)
|
|
274
|
+
{
|
|
275
|
+
"issuer" => authorization_base,
|
|
276
|
+
"authorization_endpoint" => "#{authorization_base}/authorize",
|
|
277
|
+
"token_endpoint" => "#{authorization_base}/token",
|
|
278
|
+
"registration_endpoint" => "#{authorization_base}/register",
|
|
279
|
+
"code_challenge_methods_supported" => ["S256"],
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM.
|
|
284
|
+
def server_origin!(server_url)
|
|
285
|
+
uri = URI.parse(server_url.to_s)
|
|
286
|
+
unless uri.is_a?(URI::HTTP) && uri.host
|
|
287
|
+
raise AuthorizationError,
|
|
288
|
+
"Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}."
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
port_part = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
292
|
+
"#{uri.scheme}://#{uri.host}#{port_part}"
|
|
293
|
+
rescue URI::InvalidURIError => e
|
|
294
|
+
raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}."
|
|
295
|
+
end
|
|
296
|
+
|
|
224
297
|
def fetch_authorization_server_metadata(issuer_url:)
|
|
225
298
|
urls = Discovery.authorization_server_metadata_urls(issuer_url)
|
|
226
299
|
fetch_metadata_json(urls, label: "authorization server metadata")
|
|
@@ -367,7 +440,7 @@ module MCP
|
|
|
367
440
|
end
|
|
368
441
|
|
|
369
442
|
response = begin
|
|
370
|
-
http_post_json(registration_endpoint,
|
|
443
|
+
http_post_json(registration_endpoint, registration_client_metadata)
|
|
371
444
|
rescue Faraday::Error => e
|
|
372
445
|
raise AuthorizationError,
|
|
373
446
|
"Dynamic client registration failed: #{e.class}: #{e.message}."
|
|
@@ -393,6 +466,20 @@ module MCP
|
|
|
393
466
|
info
|
|
394
467
|
end
|
|
395
468
|
|
|
469
|
+
# Returns the client metadata to submit on Dynamic Client Registration.
|
|
470
|
+
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
|
|
471
|
+
# so the authorization server can apply the matching redirect URI policy.
|
|
472
|
+
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
|
|
473
|
+
# the registered `redirect_uris`; an explicit value always wins.
|
|
474
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
|
|
475
|
+
def registration_client_metadata
|
|
476
|
+
metadata = @provider.client_metadata
|
|
477
|
+
return metadata if metadata[:application_type] || metadata["application_type"]
|
|
478
|
+
|
|
479
|
+
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
|
|
480
|
+
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
|
|
481
|
+
end
|
|
482
|
+
|
|
396
483
|
# Reads `key` from a `client_information` hash that may use either string or
|
|
397
484
|
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
|
398
485
|
# `{ client_id:, client_secret: }` and have both work.
|
|
@@ -13,6 +13,9 @@ module MCP
|
|
|
13
13
|
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
|
14
14
|
# Registration endpoint. Must include at minimum `redirect_uris`,
|
|
15
15
|
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
|
|
16
|
+
# When `application_type` is omitted, the SDK infers `"native"` or `"web"`
|
|
17
|
+
# from `redirect_uris` per SEP-837 before registering; an explicit value
|
|
18
|
+
# always wins.
|
|
16
19
|
# - `redirect_uri` - String: the redirect URI used for the authorization
|
|
17
20
|
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
18
21
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -34,6 +34,9 @@ module MCP
|
|
|
34
34
|
@started = false
|
|
35
35
|
@initialized = false
|
|
36
36
|
@server_info = nil
|
|
37
|
+
# Serializes writes to `@stdin` so a request line and a notification line emitted from
|
|
38
|
+
# different threads (e.g. cancellation) cannot interleave on the wire.
|
|
39
|
+
@write_mutex = Mutex.new
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
@@ -132,6 +135,10 @@ module MCP
|
|
|
132
135
|
@initialized
|
|
133
136
|
end
|
|
134
137
|
|
|
138
|
+
# Transports may yield once the request line has been written to `@stdin`.
|
|
139
|
+
# `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled`
|
|
140
|
+
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
|
|
141
|
+
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
|
|
135
142
|
def send_request(request:)
|
|
136
143
|
start unless @started
|
|
137
144
|
unless @initialized
|
|
@@ -139,10 +146,23 @@ module MCP
|
|
|
139
146
|
connect
|
|
140
147
|
end
|
|
141
148
|
|
|
142
|
-
|
|
149
|
+
@write_mutex.synchronize do
|
|
150
|
+
write_message(request)
|
|
151
|
+
yield if block_given?
|
|
152
|
+
end
|
|
143
153
|
read_response(request)
|
|
144
154
|
end
|
|
145
155
|
|
|
156
|
+
# Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
|
|
157
|
+
# `notifications/cancelled` for an in-flight request.
|
|
158
|
+
def send_notification(notification:)
|
|
159
|
+
start unless @started
|
|
160
|
+
connect unless @initialized
|
|
161
|
+
|
|
162
|
+
@write_mutex.synchronize { write_message(notification) }
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
146
166
|
def start
|
|
147
167
|
raise "MCP::Client::Stdio already started" if @started
|
|
148
168
|
|