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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7ace5af244e82b2df4f3fef449fd0942d3d9be46746d820f8207cfcee87af52
4
- data.tar.gz: ce4b53defad3ed24646806c2236a8d79c68597c7225fa4fcef20fd2d85031c24
3
+ metadata.gz: c0e0cdbc945ee9ec5d178aeaedb1c8af84eca0bae8db8e264f523b47b8d67c52
4
+ data.tar.gz: 6acb2d299d9ae215c26db733091ebbcbb3f6b1477c94d9b4047f9ab37c3a8f0c
5
5
  SHA512:
6
- metadata.gz: 2c2ef2a03fb1b989e691aea55d350f06b9f1f357b0acfcda4e227ca359c3a12990d06fe280ced39b1e6d620181aa9c7b8d355d5985c70d090512636da7c34f3a
7
- data.tar.gz: a75f884057bc318a98943fec8967c4725c63dd767552dc52591c8e08143474f8a28907e929cdbe0efb645426766b75314449b9df8c3c86aa90bb0a68247004ff
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 server-side cancellation of in-flight requests (notifications/cancelled)
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
- for output schemas is `object`.
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
- > [!NOTE]
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
@@ -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
@@ -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
- # version returned by the server are captured and automatically included
183
- # on subsequent requests.
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
- response = client.post("", request, session_headers)
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.
@@ -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
- write_message(request)
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: request_id,
408
+ id: generate_request_id,
381
409
  method: method,
382
410
  }
383
411
  request_body[:params] = params if params
384
412
 
385
- response = transport.send_request(request: request_body)
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
- def request_id
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:`.
@@ -130,9 +130,14 @@ module MCP
130
130
  end
131
131
  end
132
132
 
133
- def method_missing(name, ...)
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
@@ -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
- # per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
41
- # is still performed against the JSON Schema draft-04 metaschema.
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
- DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/schema#"
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
- @schema[:type] ||= "object"
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: DRAFT4_META_SCHEMA_URI,
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
- # `json_schemer` is pinned to the draft-04 metaschema, so strip top-level `$schema` before validation:
116
- # this preserves the legacy behavior of ignoring the advertised dialect URI when the SDK validates schemas.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.21.0"
4
+ VERSION = "0.22.0"
5
5
  end
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.21.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.21.0
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