mcp 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +121 -9
- data/lib/mcp/annotations.rb +7 -0
- data/lib/mcp/client/http.rb +66 -4
- data/lib/mcp/client/oauth/discovery.rb +31 -0
- data/lib/mcp/client/oauth/flow.rb +103 -16
- data/lib/mcp/client/oauth/provider.rb +3 -0
- data/lib/mcp/client/stdio.rb +21 -1
- data/lib/mcp/client.rb +197 -31
- data/lib/mcp/server/capabilities.rb +14 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +27 -19
- data/lib/mcp/server.rb +24 -2
- data/lib/mcp/server_context.rb +7 -2
- data/lib/mcp/tool/output_schema.rb +17 -0
- data/lib/mcp/tool/schema.rb +66 -8
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
data/lib/mcp/client.rb
CHANGED
|
@@ -77,7 +77,9 @@ module MCP
|
|
|
77
77
|
#
|
|
78
78
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
79
79
|
# @param protocol_version [String, nil] Protocol version to offer.
|
|
80
|
-
# @param capabilities [Hash] Capabilities advertised by the client.
|
|
80
|
+
# @param capabilities [Hash] Capabilities advertised by the client. May include
|
|
81
|
+
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
|
|
82
|
+
# e.g. `{ extensions: { "com.example/feature" => {} } }`.
|
|
81
83
|
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
|
82
84
|
# does not expose an explicit handshake.
|
|
83
85
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
@@ -105,6 +107,8 @@ module MCP
|
|
|
105
107
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
106
108
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
107
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.
|
|
108
112
|
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
|
|
109
113
|
# and `next_cursor` (String or nil).
|
|
110
114
|
#
|
|
@@ -116,9 +120,9 @@ module MCP
|
|
|
116
120
|
# cursor = page.next_cursor
|
|
117
121
|
# break unless cursor
|
|
118
122
|
# end
|
|
119
|
-
def list_tools(cursor: nil, meta: nil)
|
|
123
|
+
def list_tools(cursor: nil, meta: nil, cancellation: nil)
|
|
120
124
|
params = cursor ? { cursor: cursor } : nil
|
|
121
|
-
response = request(method: "tools/list", params: params, meta: meta)
|
|
125
|
+
response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation)
|
|
122
126
|
result = response["result"] || {}
|
|
123
127
|
|
|
124
128
|
tools = (result["tools"] || []).map do |tool|
|
|
@@ -139,6 +143,9 @@ module MCP
|
|
|
139
143
|
#
|
|
140
144
|
# Each call will make a new request - the result is not cached.
|
|
141
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.
|
|
142
149
|
# @return [Array<MCP::Client::Tool>] An array of available tools.
|
|
143
150
|
#
|
|
144
151
|
# @example
|
|
@@ -146,9 +153,9 @@ module MCP
|
|
|
146
153
|
# tools.each do |tool|
|
|
147
154
|
# puts tool.name
|
|
148
155
|
# end
|
|
149
|
-
def tools
|
|
156
|
+
def tools(cancellation: nil)
|
|
150
157
|
# TODO: consider renaming to `list_all_tools`.
|
|
151
|
-
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)
|
|
152
159
|
end
|
|
153
160
|
|
|
154
161
|
# Returns a single page of resources from the server.
|
|
@@ -156,11 +163,12 @@ module MCP
|
|
|
156
163
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
157
164
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
158
165
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
166
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
159
167
|
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
|
|
160
168
|
# and `next_cursor` (String or nil).
|
|
161
|
-
def list_resources(cursor: nil, meta: nil)
|
|
169
|
+
def list_resources(cursor: nil, meta: nil, cancellation: nil)
|
|
162
170
|
params = cursor ? { cursor: cursor } : nil
|
|
163
|
-
response = request(method: "resources/list", params: params, meta: meta)
|
|
171
|
+
response = request(method: "resources/list", params: params, meta: meta, cancellation: cancellation)
|
|
164
172
|
result = response["result"] || {}
|
|
165
173
|
|
|
166
174
|
ListResourcesResult.new(
|
|
@@ -176,10 +184,11 @@ module MCP
|
|
|
176
184
|
#
|
|
177
185
|
# Each call will make a new request - the result is not cached.
|
|
178
186
|
#
|
|
187
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
179
188
|
# @return [Array<Hash>] An array of available resources.
|
|
180
|
-
def resources
|
|
189
|
+
def resources(cancellation: nil)
|
|
181
190
|
# TODO: consider renaming to `list_all_resources`.
|
|
182
|
-
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)
|
|
183
192
|
end
|
|
184
193
|
|
|
185
194
|
# Returns a single page of resource templates from the server.
|
|
@@ -187,11 +196,12 @@ module MCP
|
|
|
187
196
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
188
197
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
189
198
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
199
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
190
200
|
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
|
|
191
201
|
# (Array<Hash>) and `next_cursor` (String or nil).
|
|
192
|
-
def list_resource_templates(cursor: nil, meta: nil)
|
|
202
|
+
def list_resource_templates(cursor: nil, meta: nil, cancellation: nil)
|
|
193
203
|
params = cursor ? { cursor: cursor } : nil
|
|
194
|
-
response = request(method: "resources/templates/list", params: params, meta: meta)
|
|
204
|
+
response = request(method: "resources/templates/list", params: params, meta: meta, cancellation: cancellation)
|
|
195
205
|
result = response["result"] || {}
|
|
196
206
|
|
|
197
207
|
ListResourceTemplatesResult.new(
|
|
@@ -207,10 +217,11 @@ module MCP
|
|
|
207
217
|
#
|
|
208
218
|
# Each call will make a new request - the result is not cached.
|
|
209
219
|
#
|
|
220
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
210
221
|
# @return [Array<Hash>] An array of available resource templates.
|
|
211
|
-
def resource_templates
|
|
222
|
+
def resource_templates(cancellation: nil)
|
|
212
223
|
# TODO: consider renaming to `list_all_resource_templates`.
|
|
213
|
-
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)
|
|
214
225
|
end
|
|
215
226
|
|
|
216
227
|
# Returns a single page of prompts from the server.
|
|
@@ -218,11 +229,12 @@ module MCP
|
|
|
218
229
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
219
230
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
220
231
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
232
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
221
233
|
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
|
|
222
234
|
# and `next_cursor` (String or nil).
|
|
223
|
-
def list_prompts(cursor: nil, meta: nil)
|
|
235
|
+
def list_prompts(cursor: nil, meta: nil, cancellation: nil)
|
|
224
236
|
params = cursor ? { cursor: cursor } : nil
|
|
225
|
-
response = request(method: "prompts/list", params: params, meta: meta)
|
|
237
|
+
response = request(method: "prompts/list", params: params, meta: meta, cancellation: cancellation)
|
|
226
238
|
result = response["result"] || {}
|
|
227
239
|
|
|
228
240
|
ListPromptsResult.new(
|
|
@@ -238,10 +250,11 @@ module MCP
|
|
|
238
250
|
#
|
|
239
251
|
# Each call will make a new request - the result is not cached.
|
|
240
252
|
#
|
|
253
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
241
254
|
# @return [Array<Hash>] An array of available prompts.
|
|
242
|
-
def prompts
|
|
255
|
+
def prompts(cancellation: nil)
|
|
243
256
|
# TODO: consider renaming to `list_all_prompts`.
|
|
244
|
-
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)
|
|
245
258
|
end
|
|
246
259
|
|
|
247
260
|
# Calls a tool via the transport layer and returns the full response from the server.
|
|
@@ -254,6 +267,8 @@ module MCP
|
|
|
254
267
|
# e.g. the W3C Trace Context keys reserved by SEP-414
|
|
255
268
|
# (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
|
|
256
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.
|
|
257
272
|
# @return [Hash] The full JSON-RPC response from the transport.
|
|
258
273
|
#
|
|
259
274
|
# @example Call by name
|
|
@@ -265,10 +280,19 @@ module MCP
|
|
|
265
280
|
# response = client.call_tool(tool: tool, arguments: { foo: "bar" })
|
|
266
281
|
# structured_content = response.dig("result", "structuredContent")
|
|
267
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
|
+
#
|
|
268
292
|
# @note
|
|
269
293
|
# The exact requirements for `arguments` are determined by the transport layer in use.
|
|
270
294
|
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
|
271
|
-
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)
|
|
272
296
|
tool_name = name || tool&.name
|
|
273
297
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
|
274
298
|
|
|
@@ -280,7 +304,7 @@ module MCP
|
|
|
280
304
|
end
|
|
281
305
|
params[:_meta] = meta_entries unless meta_entries.empty?
|
|
282
306
|
|
|
283
|
-
request(method: "tools/call", params: params)
|
|
307
|
+
request(method: "tools/call", params: params, cancellation: cancellation)
|
|
284
308
|
end
|
|
285
309
|
|
|
286
310
|
# Reads a resource from the server by URI and returns the contents.
|
|
@@ -288,9 +312,10 @@ module MCP
|
|
|
288
312
|
# @param uri [String] The URI of the resource to read.
|
|
289
313
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
290
314
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
315
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
291
316
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
|
292
|
-
def read_resource(uri:, meta: nil)
|
|
293
|
-
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)
|
|
294
319
|
|
|
295
320
|
response.dig("result", "contents") || []
|
|
296
321
|
end
|
|
@@ -300,9 +325,10 @@ module MCP
|
|
|
300
325
|
# @param name [String] The name of the prompt to get.
|
|
301
326
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
302
327
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
328
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
303
329
|
# @return [Hash] A hash containing the prompt details.
|
|
304
|
-
def get_prompt(name:, meta: nil)
|
|
305
|
-
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)
|
|
306
332
|
|
|
307
333
|
response.fetch("result", {})
|
|
308
334
|
end
|
|
@@ -315,12 +341,13 @@ module MCP
|
|
|
315
341
|
# @param context [Hash, nil] Optional context with previously resolved arguments.
|
|
316
342
|
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
317
343
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
344
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
318
345
|
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
|
|
319
|
-
def complete(ref:, argument:, context: nil, meta: nil)
|
|
346
|
+
def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil)
|
|
320
347
|
params = { ref: ref, argument: argument }
|
|
321
348
|
params[:context] = context if context
|
|
322
349
|
|
|
323
|
-
response = request(method: "completion/complete", params: params, meta: meta)
|
|
350
|
+
response = request(method: "completion/complete", params: params, meta: meta, cancellation: cancellation)
|
|
324
351
|
|
|
325
352
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
|
326
353
|
end
|
|
@@ -328,6 +355,9 @@ module MCP
|
|
|
328
355
|
# Sends a `ping` request to the server to verify the connection is alive.
|
|
329
356
|
# Per the MCP spec, the server responds with an empty result.
|
|
330
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.
|
|
331
361
|
# @return [Hash] An empty hash on success.
|
|
332
362
|
# @raise [ServerError] If the server returns a JSON-RPC error.
|
|
333
363
|
# @raise [ValidationError] If the response `result` is missing or not a Hash.
|
|
@@ -336,8 +366,8 @@ module MCP
|
|
|
336
366
|
# client.ping # => {}
|
|
337
367
|
#
|
|
338
368
|
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
|
|
339
|
-
def ping(meta: nil)
|
|
340
|
-
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"]
|
|
341
371
|
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
|
342
372
|
|
|
343
373
|
result
|
|
@@ -370,17 +400,21 @@ module MCP
|
|
|
370
400
|
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
|
371
401
|
# request-specific metadata such as W3C trace context (`traceparent`,
|
|
372
402
|
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
|
373
|
-
def request(method:, params: nil, meta: nil)
|
|
403
|
+
def request(method:, params: nil, meta: nil, cancellation: nil)
|
|
374
404
|
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
|
375
405
|
|
|
376
406
|
request_body = {
|
|
377
407
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
378
|
-
id:
|
|
408
|
+
id: generate_request_id,
|
|
379
409
|
method: method,
|
|
380
410
|
}
|
|
381
411
|
request_body[:params] = params if params
|
|
382
412
|
|
|
383
|
-
response =
|
|
413
|
+
response = if cancellation
|
|
414
|
+
dispatch_with_cancellation(request_body, cancellation)
|
|
415
|
+
else
|
|
416
|
+
transport.send_request(request: request_body)
|
|
417
|
+
end
|
|
384
418
|
|
|
385
419
|
# Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
|
|
386
420
|
if response.is_a?(Hash) && response.key?("error")
|
|
@@ -391,8 +425,140 @@ module MCP
|
|
|
391
425
|
response
|
|
392
426
|
end
|
|
393
427
|
|
|
394
|
-
|
|
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
|
|
395
432
|
SecureRandom.uuid
|
|
396
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
|
|
397
563
|
end
|
|
398
564
|
end
|
|
@@ -6,6 +6,7 @@ module MCP
|
|
|
6
6
|
def initialize(capabilities_hash = nil)
|
|
7
7
|
@completions = nil
|
|
8
8
|
@experimental = nil
|
|
9
|
+
@extensions = nil
|
|
9
10
|
@logging = nil
|
|
10
11
|
@prompts = nil
|
|
11
12
|
@resources = nil
|
|
@@ -14,6 +15,7 @@ module MCP
|
|
|
14
15
|
if capabilities_hash
|
|
15
16
|
support_completions if capabilities_hash.key?(:completions)
|
|
16
17
|
support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
|
|
18
|
+
support_extensions(capabilities_hash[:extensions]) if capabilities_hash.key?(:extensions)
|
|
17
19
|
support_logging if capabilities_hash.key?(:logging)
|
|
18
20
|
|
|
19
21
|
if capabilities_hash.key?(:prompts)
|
|
@@ -45,6 +47,17 @@ module MCP
|
|
|
45
47
|
@experimental = config || {}
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# Declares support for capability extensions per SEP-2133. Keys are
|
|
51
|
+
# extension identifiers using the reverse-DNS prefix convention
|
|
52
|
+
# (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
|
53
|
+
# values are arbitrary extension-defined configuration objects,
|
|
54
|
+
# with an empty hash meaning "supported with no settings".
|
|
55
|
+
# Repeated calls merge, so several extensions can be declared independently.
|
|
56
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133
|
|
57
|
+
def support_extensions(extensions = {})
|
|
58
|
+
@extensions = (@extensions || {}).merge(extensions || {})
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
def support_logging
|
|
49
62
|
@logging ||= {}
|
|
50
63
|
end
|
|
@@ -85,6 +98,7 @@ module MCP
|
|
|
85
98
|
{
|
|
86
99
|
completions: @completions,
|
|
87
100
|
experimental: @experimental,
|
|
101
|
+
extensions: @extensions,
|
|
88
102
|
logging: @logging,
|
|
89
103
|
prompts: @prompts,
|
|
90
104
|
resources: @resources,
|
|
@@ -85,8 +85,10 @@ module MCP
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
88
|
-
# Stateless mode
|
|
89
|
-
|
|
88
|
+
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
|
|
89
|
+
# so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
|
|
90
|
+
# a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
|
|
91
|
+
return false if @stateless
|
|
90
92
|
|
|
91
93
|
notification = {
|
|
92
94
|
jsonrpc: "2.0",
|
|
@@ -575,7 +577,9 @@ module MCP
|
|
|
575
577
|
# `notifications/initialized`) through the server so it can update session state.
|
|
576
578
|
def dispatch_notification(body_string, session_id)
|
|
577
579
|
server_session = nil
|
|
578
|
-
if
|
|
580
|
+
if @stateless
|
|
581
|
+
server_session = ephemeral_session
|
|
582
|
+
elsif session_id
|
|
579
583
|
@mutex.synchronize do
|
|
580
584
|
session = @sessions[session_id]
|
|
581
585
|
server_session = session[:server_session] if session
|
|
@@ -611,9 +615,10 @@ module MCP
|
|
|
611
615
|
|
|
612
616
|
def handle_initialization(body_string, body)
|
|
613
617
|
session_id = nil
|
|
614
|
-
server_session = nil
|
|
615
618
|
|
|
616
|
-
|
|
619
|
+
if @stateless
|
|
620
|
+
server_session = ephemeral_session
|
|
621
|
+
else
|
|
617
622
|
session_id = SecureRandom.uuid
|
|
618
623
|
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
619
624
|
|
|
@@ -626,17 +631,13 @@ module MCP
|
|
|
626
631
|
end
|
|
627
632
|
end
|
|
628
633
|
|
|
629
|
-
response =
|
|
630
|
-
server_session.handle_json(body_string)
|
|
631
|
-
else
|
|
632
|
-
@server.handle_json(body_string)
|
|
633
|
-
end
|
|
634
|
+
response = server_session.handle_json(body_string)
|
|
634
635
|
|
|
635
636
|
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
|
636
637
|
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
|
637
638
|
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
|
638
639
|
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
|
|
639
|
-
if
|
|
640
|
+
if session_id && !server_session.initialized?
|
|
640
641
|
cleanup_session(session_id)
|
|
641
642
|
session_id = nil
|
|
642
643
|
end
|
|
@@ -657,15 +658,15 @@ module MCP
|
|
|
657
658
|
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
658
659
|
server_session = nil
|
|
659
660
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
661
|
+
if @stateless
|
|
662
|
+
server_session = ephemeral_session
|
|
663
|
+
elsif session_id
|
|
664
|
+
error_response = validate_and_touch_session(session_id)
|
|
665
|
+
return error_response if error_response
|
|
664
666
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
end
|
|
667
|
+
@mutex.synchronize do
|
|
668
|
+
session = @sessions[session_id]
|
|
669
|
+
server_session = session[:server_session] if session
|
|
669
670
|
end
|
|
670
671
|
end
|
|
671
672
|
|
|
@@ -775,6 +776,13 @@ module MCP
|
|
|
775
776
|
@mutex.synchronize { @sessions.key?(session_id) }
|
|
776
777
|
end
|
|
777
778
|
|
|
779
|
+
# Each stateless POST is self-contained (SEP-2567): handlers run against an ephemeral per-request `ServerSession`
|
|
780
|
+
# so client info, logging level, and initialized state never leak onto the shared `Server` instance or across concurrent requests.
|
|
781
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
|
|
782
|
+
def ephemeral_session
|
|
783
|
+
ServerSession.new(server: @server, transport: self, session_id: nil)
|
|
784
|
+
end
|
|
785
|
+
|
|
778
786
|
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
|
|
779
787
|
# are evicted as a side effect so a live request never observes a zombie session that
|
|
780
788
|
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
|
data/lib/mcp/server.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "methods"
|
|
|
8
8
|
require_relative "logging_message_notification"
|
|
9
9
|
require_relative "progress"
|
|
10
10
|
require_relative "server_context"
|
|
11
|
+
require_relative "server/capabilities"
|
|
11
12
|
require_relative "server/pagination"
|
|
12
13
|
require_relative "server/transports"
|
|
13
14
|
|
|
@@ -142,7 +143,12 @@ module MCP
|
|
|
142
143
|
|
|
143
144
|
validate!
|
|
144
145
|
|
|
145
|
-
|
|
146
|
+
# Accept either a plain Hash or an `MCP::Server::Capabilities` builder.
|
|
147
|
+
@capabilities = if capabilities.is_a?(Capabilities)
|
|
148
|
+
capabilities.to_h
|
|
149
|
+
else
|
|
150
|
+
capabilities || default_capabilities
|
|
151
|
+
end
|
|
146
152
|
@client_capabilities = nil
|
|
147
153
|
@logging_message_notification = nil
|
|
148
154
|
|
|
@@ -630,7 +636,7 @@ module MCP
|
|
|
630
636
|
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
|
|
631
637
|
)
|
|
632
638
|
validate_tool_call_result!(tool, result)
|
|
633
|
-
result
|
|
639
|
+
serialize_structured_content_fallback(result)
|
|
634
640
|
rescue RequestHandlerError, CancelledError
|
|
635
641
|
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
|
|
636
642
|
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
|
|
@@ -795,6 +801,18 @@ module MCP
|
|
|
795
801
|
tool.output_schema.validate_result(result[:structuredContent])
|
|
796
802
|
end
|
|
797
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
|
+
|
|
798
816
|
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
|
|
799
817
|
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
|
|
800
818
|
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
|
|
@@ -810,6 +828,10 @@ module MCP
|
|
|
810
828
|
end
|
|
811
829
|
|
|
812
830
|
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
|
|
831
|
+
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
|
|
832
|
+
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
|
|
833
|
+
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
|
|
834
|
+
# See docs/building-servers.md ("Tool argument keys").
|
|
813
835
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
814
836
|
|
|
815
837
|
if accepts_server_context?(tool.method(:call))
|
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
|