mcp 0.21.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 +82 -9
- data/lib/mcp/annotations.rb +5 -0
- data/lib/mcp/client/http.rb +66 -4
- data/lib/mcp/client/stdio.rb +21 -1
- data/lib/mcp/client.rb +194 -30
- data/lib/mcp/server.rb +13 -1
- 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
|
|
|
@@ -698,8 +698,28 @@ class WeatherTool < MCP::Tool
|
|
|
698
698
|
end
|
|
699
699
|
```
|
|
700
700
|
|
|
701
|
-
Please note: in this case, you must provide `type: "array"`. The default type
|
|
702
|
-
|
|
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.
|
|
703
723
|
|
|
704
724
|
MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that:
|
|
705
725
|
|
|
@@ -729,6 +749,10 @@ Tools can return structured data alongside text content using the `structured_co
|
|
|
729
749
|
|
|
730
750
|
The structured content will be included in the JSON-RPC response as the `structuredContent` field.
|
|
731
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
|
+
|
|
732
756
|
```ruby
|
|
733
757
|
class WeatherTool < MCP::Tool
|
|
734
758
|
description "Get current weather and return structured data"
|
|
@@ -1205,12 +1229,7 @@ poll it to exit early. When a tool returns after cancellation has been observed,
|
|
|
1205
1229
|
the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
|
|
1206
1230
|
is never cancellable per the spec.
|
|
1207
1231
|
|
|
1208
|
-
|
|
1209
|
-
> Client-initiated cancellation (`Client#cancel` equivalent that would also abort
|
|
1210
|
-
> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
|
|
1211
|
-
> from the client side can be done by constructing the notification payload and writing it
|
|
1212
|
-
> directly through the transport, but the calling thread does not yet unwind automatically.
|
|
1213
|
-
> 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.
|
|
1214
1233
|
|
|
1215
1234
|
#### Server-Side: Handlers that Check for Cancellation
|
|
1216
1235
|
|
|
@@ -1319,6 +1338,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
|
|
|
1319
1338
|
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
|
|
1320
1339
|
via `server_context.cancelled?` between nested calls.
|
|
1321
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
|
+
|
|
1322
1395
|
### Ping
|
|
1323
1396
|
|
|
1324
1397
|
The MCP Ruby SDK supports the
|
data/lib/mcp/annotations.rb
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
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
|
|
8
13
|
raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1)
|
|
9
14
|
|
|
10
15
|
@audience = audience
|
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.
|
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
|
|
data/lib/mcp/client.rb
CHANGED
|
@@ -107,6 +107,8 @@ module MCP
|
|
|
107
107
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
108
108
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
109
109
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
110
|
+
# @param cancellation [MCP::Cancellation, nil] Optional token; cancelling it sends
|
|
111
|
+
# `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call.
|
|
110
112
|
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
|
|
111
113
|
# and `next_cursor` (String or nil).
|
|
112
114
|
#
|
|
@@ -118,9 +120,9 @@ module MCP
|
|
|
118
120
|
# cursor = page.next_cursor
|
|
119
121
|
# break unless cursor
|
|
120
122
|
# end
|
|
121
|
-
def list_tools(cursor: nil, meta: nil)
|
|
123
|
+
def list_tools(cursor: nil, meta: nil, cancellation: nil)
|
|
122
124
|
params = cursor ? { cursor: cursor } : nil
|
|
123
|
-
response = request(method: "tools/list", params: params, meta: meta)
|
|
125
|
+
response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation)
|
|
124
126
|
result = response["result"] || {}
|
|
125
127
|
|
|
126
128
|
tools = (result["tools"] || []).map do |tool|
|
|
@@ -141,6 +143,9 @@ module MCP
|
|
|
141
143
|
#
|
|
142
144
|
# Each call will make a new request - the result is not cached.
|
|
143
145
|
#
|
|
146
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
147
|
+
# Cancelling it aborts whichever page is currently in flight; pages already returned are kept,
|
|
148
|
+
# but the call raises `MCP::CancelledError` instead of returning the partial set.
|
|
144
149
|
# @return [Array<MCP::Client::Tool>] An array of available tools.
|
|
145
150
|
#
|
|
146
151
|
# @example
|
|
@@ -148,9 +153,9 @@ module MCP
|
|
|
148
153
|
# tools.each do |tool|
|
|
149
154
|
# puts tool.name
|
|
150
155
|
# end
|
|
151
|
-
def tools
|
|
156
|
+
def tools(cancellation: nil)
|
|
152
157
|
# TODO: consider renaming to `list_all_tools`.
|
|
153
|
-
fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools)
|
|
158
|
+
fetch_all_pages { |cursor| list_tools(cursor: cursor, cancellation: cancellation) }.flat_map(&:tools)
|
|
154
159
|
end
|
|
155
160
|
|
|
156
161
|
# Returns a single page of resources from the server.
|
|
@@ -158,11 +163,12 @@ module MCP
|
|
|
158
163
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
159
164
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
160
165
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
166
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
161
167
|
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
|
|
162
168
|
# and `next_cursor` (String or nil).
|
|
163
|
-
def list_resources(cursor: nil, meta: nil)
|
|
169
|
+
def list_resources(cursor: nil, meta: nil, cancellation: nil)
|
|
164
170
|
params = cursor ? { cursor: cursor } : nil
|
|
165
|
-
response = request(method: "resources/list", params: params, meta: meta)
|
|
171
|
+
response = request(method: "resources/list", params: params, meta: meta, cancellation: cancellation)
|
|
166
172
|
result = response["result"] || {}
|
|
167
173
|
|
|
168
174
|
ListResourcesResult.new(
|
|
@@ -178,10 +184,11 @@ module MCP
|
|
|
178
184
|
#
|
|
179
185
|
# Each call will make a new request - the result is not cached.
|
|
180
186
|
#
|
|
187
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
181
188
|
# @return [Array<Hash>] An array of available resources.
|
|
182
|
-
def resources
|
|
189
|
+
def resources(cancellation: nil)
|
|
183
190
|
# TODO: consider renaming to `list_all_resources`.
|
|
184
|
-
fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources)
|
|
191
|
+
fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources)
|
|
185
192
|
end
|
|
186
193
|
|
|
187
194
|
# Returns a single page of resource templates from the server.
|
|
@@ -189,11 +196,12 @@ module MCP
|
|
|
189
196
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
190
197
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
191
198
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
199
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
192
200
|
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
|
|
193
201
|
# (Array<Hash>) and `next_cursor` (String or nil).
|
|
194
|
-
def list_resource_templates(cursor: nil, meta: nil)
|
|
202
|
+
def list_resource_templates(cursor: nil, meta: nil, cancellation: nil)
|
|
195
203
|
params = cursor ? { cursor: cursor } : nil
|
|
196
|
-
response = request(method: "resources/templates/list", params: params, meta: meta)
|
|
204
|
+
response = request(method: "resources/templates/list", params: params, meta: meta, cancellation: cancellation)
|
|
197
205
|
result = response["result"] || {}
|
|
198
206
|
|
|
199
207
|
ListResourceTemplatesResult.new(
|
|
@@ -209,10 +217,11 @@ module MCP
|
|
|
209
217
|
#
|
|
210
218
|
# Each call will make a new request - the result is not cached.
|
|
211
219
|
#
|
|
220
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
212
221
|
# @return [Array<Hash>] An array of available resource templates.
|
|
213
|
-
def resource_templates
|
|
222
|
+
def resource_templates(cancellation: nil)
|
|
214
223
|
# TODO: consider renaming to `list_all_resource_templates`.
|
|
215
|
-
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates)
|
|
224
|
+
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates)
|
|
216
225
|
end
|
|
217
226
|
|
|
218
227
|
# Returns a single page of prompts from the server.
|
|
@@ -220,11 +229,12 @@ module MCP
|
|
|
220
229
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
221
230
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
222
231
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
232
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
223
233
|
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
|
|
224
234
|
# and `next_cursor` (String or nil).
|
|
225
|
-
def list_prompts(cursor: nil, meta: nil)
|
|
235
|
+
def list_prompts(cursor: nil, meta: nil, cancellation: nil)
|
|
226
236
|
params = cursor ? { cursor: cursor } : nil
|
|
227
|
-
response = request(method: "prompts/list", params: params, meta: meta)
|
|
237
|
+
response = request(method: "prompts/list", params: params, meta: meta, cancellation: cancellation)
|
|
228
238
|
result = response["result"] || {}
|
|
229
239
|
|
|
230
240
|
ListPromptsResult.new(
|
|
@@ -240,10 +250,11 @@ module MCP
|
|
|
240
250
|
#
|
|
241
251
|
# Each call will make a new request - the result is not cached.
|
|
242
252
|
#
|
|
253
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
243
254
|
# @return [Array<Hash>] An array of available prompts.
|
|
244
|
-
def prompts
|
|
255
|
+
def prompts(cancellation: nil)
|
|
245
256
|
# TODO: consider renaming to `list_all_prompts`.
|
|
246
|
-
fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts)
|
|
257
|
+
fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts)
|
|
247
258
|
end
|
|
248
259
|
|
|
249
260
|
# Calls a tool via the transport layer and returns the full response from the server.
|
|
@@ -256,6 +267,8 @@ module MCP
|
|
|
256
267
|
# e.g. the W3C Trace Context keys reserved by SEP-414
|
|
257
268
|
# (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
|
|
258
269
|
# `progress_token` takes precedence over a `progressToken` entry in `meta`.
|
|
270
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token. Cancelling it from another thread
|
|
271
|
+
# sends `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call.
|
|
259
272
|
# @return [Hash] The full JSON-RPC response from the transport.
|
|
260
273
|
#
|
|
261
274
|
# @example Call by name
|
|
@@ -267,10 +280,19 @@ module MCP
|
|
|
267
280
|
# response = client.call_tool(tool: tool, arguments: { foo: "bar" })
|
|
268
281
|
# structured_content = response.dig("result", "structuredContent")
|
|
269
282
|
#
|
|
283
|
+
# @example Cancellable call
|
|
284
|
+
# cancellation = MCP::Cancellation.new
|
|
285
|
+
# Thread.new do
|
|
286
|
+
# client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
|
287
|
+
# rescue MCP::CancelledError
|
|
288
|
+
# # cleanup
|
|
289
|
+
# end
|
|
290
|
+
# cancellation.cancel(reason: "user pressed cancel")
|
|
291
|
+
#
|
|
270
292
|
# @note
|
|
271
293
|
# The exact requirements for `arguments` are determined by the transport layer in use.
|
|
272
294
|
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
|
273
|
-
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
|
|
295
|
+
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil)
|
|
274
296
|
tool_name = name || tool&.name
|
|
275
297
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
|
276
298
|
|
|
@@ -282,7 +304,7 @@ module MCP
|
|
|
282
304
|
end
|
|
283
305
|
params[:_meta] = meta_entries unless meta_entries.empty?
|
|
284
306
|
|
|
285
|
-
request(method: "tools/call", params: params)
|
|
307
|
+
request(method: "tools/call", params: params, cancellation: cancellation)
|
|
286
308
|
end
|
|
287
309
|
|
|
288
310
|
# Reads a resource from the server by URI and returns the contents.
|
|
@@ -290,9 +312,10 @@ module MCP
|
|
|
290
312
|
# @param uri [String] The URI of the resource to read.
|
|
291
313
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
292
314
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
315
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
293
316
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
|
294
|
-
def read_resource(uri:, meta: nil)
|
|
295
|
-
response = request(method: "resources/read", params: { uri: uri }, meta: meta)
|
|
317
|
+
def read_resource(uri:, meta: nil, cancellation: nil)
|
|
318
|
+
response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation)
|
|
296
319
|
|
|
297
320
|
response.dig("result", "contents") || []
|
|
298
321
|
end
|
|
@@ -302,9 +325,10 @@ module MCP
|
|
|
302
325
|
# @param name [String] The name of the prompt to get.
|
|
303
326
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
304
327
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
328
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
305
329
|
# @return [Hash] A hash containing the prompt details.
|
|
306
|
-
def get_prompt(name:, meta: nil)
|
|
307
|
-
response = request(method: "prompts/get", params: { name: name }, meta: meta)
|
|
330
|
+
def get_prompt(name:, meta: nil, cancellation: nil)
|
|
331
|
+
response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation)
|
|
308
332
|
|
|
309
333
|
response.fetch("result", {})
|
|
310
334
|
end
|
|
@@ -317,12 +341,13 @@ module MCP
|
|
|
317
341
|
# @param context [Hash, nil] Optional context with previously resolved arguments.
|
|
318
342
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
319
343
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
344
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
320
345
|
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
|
|
321
|
-
def complete(ref:, argument:, context: nil, meta: nil)
|
|
346
|
+
def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil)
|
|
322
347
|
params = { ref: ref, argument: argument }
|
|
323
348
|
params[:context] = context if context
|
|
324
349
|
|
|
325
|
-
response = request(method: "completion/complete", params: params, meta: meta)
|
|
350
|
+
response = request(method: "completion/complete", params: params, meta: meta, cancellation: cancellation)
|
|
326
351
|
|
|
327
352
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
|
328
353
|
end
|
|
@@ -330,6 +355,9 @@ module MCP
|
|
|
330
355
|
# Sends a `ping` request to the server to verify the connection is alive.
|
|
331
356
|
# Per the MCP spec, the server responds with an empty result.
|
|
332
357
|
#
|
|
358
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
359
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
360
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
333
361
|
# @return [Hash] An empty hash on success.
|
|
334
362
|
# @raise [ServerError] If the server returns a JSON-RPC error.
|
|
335
363
|
# @raise [ValidationError] If the response `result` is missing or not a Hash.
|
|
@@ -338,8 +366,8 @@ module MCP
|
|
|
338
366
|
# client.ping # => {}
|
|
339
367
|
#
|
|
340
368
|
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
|
|
341
|
-
def ping(meta: nil)
|
|
342
|
-
result = request(method: Methods::PING, meta: meta)["result"]
|
|
369
|
+
def ping(meta: nil, cancellation: nil)
|
|
370
|
+
result = request(method: Methods::PING, meta: meta, cancellation: cancellation)["result"]
|
|
343
371
|
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
|
344
372
|
|
|
345
373
|
result
|
|
@@ -372,17 +400,21 @@ module MCP
|
|
|
372
400
|
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
|
373
401
|
# request-specific metadata such as W3C trace context (`traceparent`,
|
|
374
402
|
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
|
375
|
-
def request(method:, params: nil, meta: nil)
|
|
403
|
+
def request(method:, params: nil, meta: nil, cancellation: nil)
|
|
376
404
|
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
|
377
405
|
|
|
378
406
|
request_body = {
|
|
379
407
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
380
|
-
id:
|
|
408
|
+
id: generate_request_id,
|
|
381
409
|
method: method,
|
|
382
410
|
}
|
|
383
411
|
request_body[:params] = params if params
|
|
384
412
|
|
|
385
|
-
response =
|
|
413
|
+
response = if cancellation
|
|
414
|
+
dispatch_with_cancellation(request_body, cancellation)
|
|
415
|
+
else
|
|
416
|
+
transport.send_request(request: request_body)
|
|
417
|
+
end
|
|
386
418
|
|
|
387
419
|
# Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
|
|
388
420
|
if response.is_a?(Hash) && response.key?("error")
|
|
@@ -393,8 +425,140 @@ module MCP
|
|
|
393
425
|
response
|
|
394
426
|
end
|
|
395
427
|
|
|
396
|
-
|
|
428
|
+
# Generates a fresh JSON-RPC request id for an outgoing request.
|
|
429
|
+
# Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
|
|
430
|
+
# an `MCP::Cancellation` token instead.
|
|
431
|
+
def generate_request_id
|
|
397
432
|
SecureRandom.uuid
|
|
398
433
|
end
|
|
434
|
+
|
|
435
|
+
# Sends `request_body` while watching `cancellation`. The actual blocking `transport.send_request` runs on
|
|
436
|
+
# a worker thread; the calling thread waits on a Queue that is woken either by the response or by a cancel signal
|
|
437
|
+
# (whichever arrives first - matching the server-side `StreamableHTTPTransport#cancel_pending_request` race contract).
|
|
438
|
+
#
|
|
439
|
+
# When a cancel wins the race, the calling thread raises `MCP::CancelledError` immediately and the `notifications/cancelled`
|
|
440
|
+
# dispatch runs fire-and-forget on its own thread. We deliberately do not wait for that dispatch here: the calling thread
|
|
441
|
+
# must not be blocked by a slow or stalled transport write on the cancel path.
|
|
442
|
+
# The worker thread is also not force-killed; it stays blocked on the underlying I/O until the server actually responds
|
|
443
|
+
# (or the transport closes). This is the same trade-off the server-side `StreamableHTTPTransport#send_request` accepts and
|
|
444
|
+
# is noted in the README's Cancellation section.
|
|
445
|
+
def dispatch_with_cancellation(request_body, cancellation)
|
|
446
|
+
unless transport.respond_to?(:send_notification)
|
|
447
|
+
raise NoMethodError, "Cancellation support requires a transport that responds to `send_notification(notification:)` " \
|
|
448
|
+
"so `notifications/cancelled` can be delivered to the peer. The bundled `MCP::Client::Stdio` and `MCP::Client::HTTP` transports " \
|
|
449
|
+
"implement this interface; custom transports must add it before passing `cancellation:` to a request method."
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
cancellation.raise_if_cancelled!
|
|
453
|
+
|
|
454
|
+
request_id = request_body[:id]
|
|
455
|
+
queue = Queue.new
|
|
456
|
+
|
|
457
|
+
# First-writer-wins gate. Whichever side (worker or on_cancel) flips `completed` first owns the queue's single slot; the loser bails.
|
|
458
|
+
# This closes the late-cancel window between the worker pushing `:response` and the main thread completing `dispatch_with_cancellation`,
|
|
459
|
+
# where a callback firing in that gap would otherwise emit a stray `notifications/cancelled` for a request that already succeeded.
|
|
460
|
+
completion_mutex = Mutex.new
|
|
461
|
+
completed = false
|
|
462
|
+
sent_mutex = Mutex.new
|
|
463
|
+
sent_cond = ConditionVariable.new
|
|
464
|
+
request_sent = false
|
|
465
|
+
signal_sent = lambda do
|
|
466
|
+
sent_mutex.synchronize do
|
|
467
|
+
unless request_sent
|
|
468
|
+
request_sent = true
|
|
469
|
+
sent_cond.broadcast
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
Thread.new do
|
|
475
|
+
Thread.current.report_on_exception = false
|
|
476
|
+
begin
|
|
477
|
+
result = transport.send_request(request: request_body, &signal_sent)
|
|
478
|
+
completion_mutex.synchronize do
|
|
479
|
+
next if completed
|
|
480
|
+
|
|
481
|
+
completed = true
|
|
482
|
+
queue.push([:response, result])
|
|
483
|
+
end
|
|
484
|
+
rescue StandardError => e
|
|
485
|
+
completion_mutex.synchronize do
|
|
486
|
+
next if completed
|
|
487
|
+
|
|
488
|
+
completed = true
|
|
489
|
+
queue.push([:error, e])
|
|
490
|
+
end
|
|
491
|
+
ensure
|
|
492
|
+
# Unblock any waiting cancel-dispatch thread on completion (or error)
|
|
493
|
+
# so it does not stall when the transport ignored the block.
|
|
494
|
+
signal_sent.call
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
cancel_hook = cancellation.on_cancel do |reason|
|
|
499
|
+
should_dispatch = completion_mutex.synchronize do
|
|
500
|
+
next false if completed
|
|
501
|
+
|
|
502
|
+
completed = true
|
|
503
|
+
|
|
504
|
+
# Wake the waiting thread first, then dispatch the `notifications/cancelled` send on a separate thread.
|
|
505
|
+
# The wake-first ordering matters because the cancellation callback can run on the worker thread itself
|
|
506
|
+
# (e.g. a tool that triggers cancel from within `transport.send_request`), and a synchronous `send_notification`
|
|
507
|
+
# here would deadlock when the worker holds a transport-level mutex.
|
|
508
|
+
queue.push([:cancelled, reason])
|
|
509
|
+
true
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
next unless should_dispatch
|
|
513
|
+
|
|
514
|
+
Thread.new do
|
|
515
|
+
Thread.current.report_on_exception = false
|
|
516
|
+
# Wait for the worker's send-boundary signal before issuing `notifications/cancelled`. Bundled transports raise
|
|
517
|
+
# the signal via `&on_sent` from inside `send_request`; custom transports that ignore the block still raise it
|
|
518
|
+
# via the worker's `ensure -> signal_sent.call`, so the loop is bounded by worker termination rather than by wall-clock time.
|
|
519
|
+
# The previous fixed-duration fallback could release this thread before the worker reached its send-boundary at all,
|
|
520
|
+
# allowing the cancel to be issued without any prior request commitment - which the spec only covers under
|
|
521
|
+
# the receiver's MAY-ignore-unknown-id clause and is therefore avoided here.
|
|
522
|
+
sent_mutex.synchronize do
|
|
523
|
+
sent_cond.wait(sent_mutex) until request_sent
|
|
524
|
+
end
|
|
525
|
+
cancel(request_id: request_id, reason: reason)
|
|
526
|
+
rescue StandardError
|
|
527
|
+
# Swallow notification-send failures: the calling thread has already been woken with `:cancelled` above and
|
|
528
|
+
# is on its way to raising `MCP::CancelledError`.
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
tag, payload = queue.pop
|
|
533
|
+
|
|
534
|
+
case tag
|
|
535
|
+
when :response
|
|
536
|
+
payload
|
|
537
|
+
when :error
|
|
538
|
+
raise payload
|
|
539
|
+
when :cancelled
|
|
540
|
+
raise MCP::CancelledError.new(request_id: request_id, reason: payload)
|
|
541
|
+
end
|
|
542
|
+
ensure
|
|
543
|
+
cancellation&.off_cancel(cancel_hook) if cancel_hook
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Sends `notifications/cancelled` to the server for an in-flight request.
|
|
547
|
+
# Per spec, this is fire-and-forget: the server is expected to stop processing and suppress its response,
|
|
548
|
+
# with no acknowledgement returned. Driven internally by {#dispatch_with_cancellation} when a request's
|
|
549
|
+
# `cancellation` token fires.
|
|
550
|
+
def cancel(request_id:, reason: nil)
|
|
551
|
+
params = { requestId: request_id }
|
|
552
|
+
params[:reason] = reason if reason
|
|
553
|
+
|
|
554
|
+
notification = {
|
|
555
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
556
|
+
method: Methods::NOTIFICATIONS_CANCELLED,
|
|
557
|
+
params: params,
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
transport.send_notification(notification: notification)
|
|
561
|
+
nil
|
|
562
|
+
end
|
|
399
563
|
end
|
|
400
564
|
end
|
data/lib/mcp/server.rb
CHANGED
|
@@ -636,7 +636,7 @@ module MCP
|
|
|
636
636
|
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
|
|
637
637
|
)
|
|
638
638
|
validate_tool_call_result!(tool, result)
|
|
639
|
-
result
|
|
639
|
+
serialize_structured_content_fallback(result)
|
|
640
640
|
rescue RequestHandlerError, CancelledError
|
|
641
641
|
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
|
|
642
642
|
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
|
|
@@ -801,6 +801,18 @@ module MCP
|
|
|
801
801
|
tool.output_schema.validate_result(result[:structuredContent])
|
|
802
802
|
end
|
|
803
803
|
|
|
804
|
+
# Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
|
|
805
|
+
# Clients on older protocol versions may only read `content`,
|
|
806
|
+
# so when a tool returns non-object structured content without providing
|
|
807
|
+
# any content blocks, mirror the value into `content` as serialized JSON text.
|
|
808
|
+
def serialize_structured_content_fallback(result)
|
|
809
|
+
structured = result[:structuredContent]
|
|
810
|
+
return result if structured.nil? || structured.is_a?(Hash)
|
|
811
|
+
return result unless result[:content].nil? || result[:content].empty?
|
|
812
|
+
|
|
813
|
+
result.merge(content: [{ type: "text", text: JSON.generate(structured) }])
|
|
814
|
+
end
|
|
815
|
+
|
|
804
816
|
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
|
|
805
817
|
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
|
|
806
818
|
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
|
data/lib/mcp/server_context.rb
CHANGED
|
@@ -130,9 +130,14 @@ module MCP
|
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
# Forward arguments explicitly with `*args, **kwargs, &block` rather than the `...` forwarding syntax.
|
|
134
|
+
# The gem supports Ruby 2.7.0 (see `required_ruby_version`), but RuboCop's Parser backend only runs on Ruby 2.7.8,
|
|
135
|
+
# so leading-argument forwarding like `def method_missing(name, ...)` is allowed by the linter even though it
|
|
136
|
+
# raises a `SyntaxError` on Ruby 2.7.0 through 2.7.2 (it was added in Ruby 2.7.3). Explicit forwarding keeps
|
|
137
|
+
# this method loadable on Ruby 2.7.0.
|
|
138
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
134
139
|
if @context.respond_to?(name)
|
|
135
|
-
@context.public_send(name,
|
|
140
|
+
@context.public_send(name, *args, **kwargs, &block)
|
|
136
141
|
else
|
|
137
142
|
super
|
|
138
143
|
end
|
|
@@ -7,12 +7,29 @@ module MCP
|
|
|
7
7
|
class OutputSchema < Schema
|
|
8
8
|
class ValidationError < StandardError; end
|
|
9
9
|
|
|
10
|
+
# Root-level keywords whose presence means the user already chose a root schema shape,
|
|
11
|
+
# so no `type: "object"` default should be merged in.
|
|
12
|
+
ROOT_SCHEMA_KEYWORDS = [:type, :"$ref", :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze
|
|
13
|
+
|
|
10
14
|
def validate_result(result)
|
|
11
15
|
errors = fully_validate(result)
|
|
12
16
|
if errors.any?
|
|
13
17
|
raise ValidationError, "Invalid result: #{errors.join(", ")}"
|
|
14
18
|
end
|
|
15
19
|
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Per SEP-2106, an output schema may be ANY valid JSON Schema 2020-12 document: object, array, primitive,
|
|
24
|
+
# or a root-level composition.
|
|
25
|
+
# Default the root to an object only when no root schema keyword is present, which preserves the wire output
|
|
26
|
+
# of the common `properties`-only shape while leaving e.g. `{ type: "array" }` or `{ oneOf: [...] }` untouched
|
|
27
|
+
# (the old unconditional default merged `type: "object"` into root combinators, producing a wrong schema).
|
|
28
|
+
def apply_default_root_type!
|
|
29
|
+
return if ROOT_SCHEMA_KEYWORDS.any? { |keyword| @schema.key?(keyword) }
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
16
33
|
end
|
|
17
34
|
end
|
|
18
35
|
end
|
data/lib/mcp/tool/schema.rb
CHANGED
|
@@ -36,16 +36,29 @@ module MCP
|
|
|
36
36
|
end
|
|
37
37
|
VALIDATION_CACHE = ValidationCache.new
|
|
38
38
|
|
|
39
|
-
# JSON Schema 2020-12 is the default dialect for MCP schema definitions
|
|
40
|
-
#
|
|
41
|
-
#
|
|
39
|
+
# JSON Schema 2020-12 is the default dialect for MCP schema definitions per MCP 2025-11-25 (SEP-1613),
|
|
40
|
+
# and SEP-2106 requires tool schemas to conform to the full 2020-12 vocabulary. Both emission and
|
|
41
|
+
# runtime validation use this dialect. Because MCP mandates 2020-12, the SDK validates against it
|
|
42
|
+
# regardless of any `$schema` a document embeds; for compliant schemas this is the same dialect
|
|
43
|
+
# the Python SDK's `jsonschema.validate` resolves to.
|
|
42
44
|
JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
# Resource bounds for schema compilation, mirroring the TypeScript SDK's schema bounds (SEP-2106):
|
|
47
|
+
# schemas may use the full JSON Schema 2020-12 vocabulary including composition keywords and `$ref`,
|
|
48
|
+
# so adversarial documents must be rejected before they can cause excessive validation cost.
|
|
49
|
+
# Only same-document references (starting with `#`) are accepted, so schema handling can never trigger network
|
|
50
|
+
# or file access.
|
|
51
|
+
MAX_SCHEMA_DEPTH = 64
|
|
52
|
+
MAX_SUBSCHEMA_COUNT = 10_000
|
|
53
|
+
|
|
54
|
+
# Reference keywords whose targets the SDK refuses to dereference. Both `$ref` and `$dynamicRef` may carry
|
|
55
|
+
# an absolute URI under JSON Schema 2020-12, so a non-same-document value is an external reference.
|
|
56
|
+
REFERENCE_KEYWORDS = [:"$ref", :"$dynamicRef"].freeze
|
|
45
57
|
|
|
46
58
|
def initialize(schema = {})
|
|
47
59
|
@schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
|
|
48
|
-
|
|
60
|
+
apply_default_root_type!
|
|
61
|
+
validate_schema_bounds!
|
|
49
62
|
validate_schema!
|
|
50
63
|
end
|
|
51
64
|
|
|
@@ -61,6 +74,48 @@ module MCP
|
|
|
61
74
|
|
|
62
75
|
private
|
|
63
76
|
|
|
77
|
+
# Root-type defaulting hook. The base class preserves the historical behavior of defaulting the root
|
|
78
|
+
# to an object schema; `OutputSchema` overrides this because SEP-2106 allows any root schema there.
|
|
79
|
+
def apply_default_root_type!
|
|
80
|
+
@schema[:type] ||= "object"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Enforces `MAX_SCHEMA_DEPTH` / `MAX_SUBSCHEMA_COUNT` and the same-document reference rule over
|
|
84
|
+
# the whole schema document.
|
|
85
|
+
def validate_schema_bounds!
|
|
86
|
+
subschema_count = 0
|
|
87
|
+
stack = [[@schema, 1]]
|
|
88
|
+
|
|
89
|
+
until stack.empty?
|
|
90
|
+
node, depth = stack.pop
|
|
91
|
+
if depth > MAX_SCHEMA_DEPTH
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"Invalid JSON Schema: nesting exceeds the maximum depth of #{MAX_SCHEMA_DEPTH}."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
case node
|
|
97
|
+
when Hash
|
|
98
|
+
subschema_count += 1
|
|
99
|
+
if subschema_count > MAX_SUBSCHEMA_COUNT
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"Invalid JSON Schema: document exceeds the maximum of #{MAX_SUBSCHEMA_COUNT} subschema objects."
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
REFERENCE_KEYWORDS.each do |keyword|
|
|
105
|
+
ref = node[keyword]
|
|
106
|
+
next unless ref.is_a?(String) && !ref.start_with?("#")
|
|
107
|
+
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"Invalid JSON Schema: only same-document #{keyword} (starting with '#') is supported, got #{ref.inspect}."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
node.each_value { |child| stack << [child, depth + 1] }
|
|
113
|
+
when Array
|
|
114
|
+
node.each { |child| stack << [child, depth + 1] }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
64
119
|
def stringify(obj)
|
|
65
120
|
case obj
|
|
66
121
|
when Hash
|
|
@@ -78,13 +133,16 @@ module MCP
|
|
|
78
133
|
# Memoized per Schema instance because schema content is fixed at construction,
|
|
79
134
|
# so the compiled schemer is reusable across many `fully_validate` calls.
|
|
80
135
|
#
|
|
136
|
+
# Validated against the JSON Schema 2020-12 metaschema per SEP-2106, so `$defs`/`$ref` and
|
|
137
|
+
# the rest of the 2020-12 vocabulary resolve natively.
|
|
138
|
+
#
|
|
81
139
|
# `format: false` preserves the legacy behavior of the previous `json-schema` based implementation,
|
|
82
140
|
# which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as
|
|
83
141
|
# `ArgumentError` so callers see the same exception class they used to.
|
|
84
142
|
def schemer
|
|
85
143
|
@schemer ||= JSONSchemer.schema(
|
|
86
144
|
stringify(schema_for_validation),
|
|
87
|
-
meta_schema:
|
|
145
|
+
meta_schema: JSON_SCHEMA_2020_12_URI,
|
|
88
146
|
format: false,
|
|
89
147
|
)
|
|
90
148
|
rescue RegexpError => e
|
|
@@ -112,8 +170,8 @@ module MCP
|
|
|
112
170
|
VALIDATION_CACHE.store(key)
|
|
113
171
|
end
|
|
114
172
|
|
|
115
|
-
#
|
|
116
|
-
#
|
|
173
|
+
# Strip the top-level `$schema` before validation so the SDK always validates against
|
|
174
|
+
# the 2020-12 metaschema (SEP-2106) regardless of any dialect URI a caller embedded in the document.
|
|
117
175
|
def schema_for_validation
|
|
118
176
|
return @schema unless @schema.key?(:"$schema")
|
|
119
177
|
|
data/lib/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.22.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -90,7 +90,7 @@ licenses:
|
|
|
90
90
|
- Apache-2.0
|
|
91
91
|
metadata:
|
|
92
92
|
allowed_push_host: https://rubygems.org
|
|
93
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.
|
|
93
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.22.0
|
|
94
94
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
95
95
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
96
96
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|