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.
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: request_id,
408
+ id: generate_request_id,
379
409
  method: method,
380
410
  }
381
411
  request_body[:params] = params if params
382
412
 
383
- 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
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
- 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
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 doesn't support notifications
89
- raise "Stateless mode does not support notifications" if @stateless
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 session_id && !@stateless
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
- unless @stateless
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 = if server_session
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 server_session && !server_session.initialized?
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
- unless @stateless
661
- if session_id
662
- error_response = validate_and_touch_session(session_id)
663
- return error_response if error_response
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
- @mutex.synchronize do
666
- session = @sessions[session_id]
667
- server_session = session[:server_session] if session
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
- @capabilities = capabilities || default_capabilities
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))
@@ -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