mcp 0.13.0 → 0.15.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/server.rb CHANGED
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../json_rpc_handler"
4
+ require_relative "cancellation"
5
+ require_relative "cancelled_error"
4
6
  require_relative "instrumentation"
5
7
  require_relative "methods"
6
8
  require_relative "logging_message_notification"
7
9
  require_relative "progress"
8
10
  require_relative "server_context"
11
+ require_relative "server/pagination"
9
12
  require_relative "server/transports"
10
13
 
11
14
  module MCP
@@ -65,9 +68,10 @@ module MCP
65
68
  end
66
69
 
67
70
  include Instrumentation
71
+ include Pagination
68
72
 
69
73
  attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
70
- attr_reader :client_capabilities
74
+ attr_reader :page_size, :client_capabilities
71
75
 
72
76
  def initialize(
73
77
  description: nil,
@@ -84,6 +88,7 @@ module MCP
84
88
  server_context: nil,
85
89
  configuration: nil,
86
90
  capabilities: nil,
91
+ page_size: nil,
87
92
  transport: nil
88
93
  )
89
94
  @description = description
@@ -100,6 +105,7 @@ module MCP
100
105
  @resource_templates = resource_templates
101
106
  @resource_index = index_resources_by_uri(resources)
102
107
  @server_context = server_context
108
+ self.page_size = page_size
103
109
  @configuration = MCP.configuration.merge(configuration)
104
110
  @client = nil
105
111
 
@@ -113,6 +119,8 @@ module MCP
113
119
  Methods::RESOURCES_LIST => method(:list_resources),
114
120
  Methods::RESOURCES_READ => method(:read_resource_no_content),
115
121
  Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
122
+ Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
123
+ Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
116
124
  Methods::TOOLS_LIST => method(:list_tools),
117
125
  Methods::TOOLS_CALL => method(:call_tool),
118
126
  Methods::PROMPTS_LIST => method(:list_prompts),
@@ -121,12 +129,9 @@ module MCP
121
129
  Methods::PING => ->(_) { {} },
122
130
  Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
123
131
  Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
132
+ Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
124
133
  Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
125
134
  Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
126
-
127
- # No op handlers for currently unsupported methods
128
- Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
129
- Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
130
135
  }
131
136
  @transport = transport
132
137
  end
@@ -182,6 +187,14 @@ module MCP
182
187
  @handlers[method_name] = block
183
188
  end
184
189
 
190
+ def page_size=(page_size)
191
+ unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0)
192
+ raise ArgumentError, "page_size must be nil or a positive integer"
193
+ end
194
+
195
+ @page_size = page_size
196
+ end
197
+
185
198
  def notify_tools_list_changed
186
199
  return unless @transport
187
200
 
@@ -218,6 +231,14 @@ module MCP
218
231
  report_exception(e, { notification: "log_message" })
219
232
  end
220
233
 
234
+ # Sets a handler for `notifications/roots/list_changed` notifications.
235
+ # Called when a client notifies the server that its filesystem roots have changed.
236
+ #
237
+ # @yield [params] The notification params (typically `nil`).
238
+ def roots_list_changed_handler(&block)
239
+ @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
240
+ end
241
+
221
242
  # Sets a custom handler for `resources/read` requests.
222
243
  # The block receives the parsed request params and should return resource
223
244
  # contents. The return value is set as the `contents` field of the response.
@@ -237,6 +258,24 @@ module MCP
237
258
  @handlers[Methods::COMPLETION_COMPLETE] = block
238
259
  end
239
260
 
261
+ # Sets a custom handler for `resources/subscribe` requests.
262
+ # The block receives the parsed request params. The return value is
263
+ # ignored; the response is always an empty result `{}` per the MCP specification.
264
+ #
265
+ # @yield [params] The request params containing `:uri`.
266
+ def resources_subscribe_handler(&block)
267
+ @handlers[Methods::RESOURCES_SUBSCRIBE] = block
268
+ end
269
+
270
+ # Sets a custom handler for `resources/unsubscribe` requests.
271
+ # The block receives the parsed request params. The return value is
272
+ # ignored; the response is always an empty result `{}` per the MCP specification.
273
+ #
274
+ # @yield [params] The request params containing `:uri`.
275
+ def resources_unsubscribe_handler(&block)
276
+ @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
277
+ end
278
+
240
279
  def build_sampling_params(
241
280
  capabilities,
242
281
  messages:,
@@ -347,6 +386,13 @@ module MCP
347
386
  end
348
387
 
349
388
  def handle_request(request, method, session: nil, related_request_id: nil)
389
+ # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
390
+ # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
391
+ # and rescue blocks below.
392
+ if method == Methods::NOTIFICATIONS_CANCELLED
393
+ return ->(params) { handle_cancelled_notification(params, session: session) }
394
+ end
395
+
350
396
  handler = @handlers[method]
351
397
  unless handler
352
398
  instrument_call("unsupported_method", server_context: { request: request }) do
@@ -358,6 +404,12 @@ module MCP
358
404
 
359
405
  Methods.ensure_capability!(method, capabilities)
360
406
 
407
+ # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
408
+ # so do not track it in the in-flight registry.
409
+ cancellation = if related_request_id && method != Methods::INITIALIZE
410
+ session&.register_in_flight(related_request_id)
411
+ end
412
+
361
413
  ->(params) {
362
414
  reported_exception = nil
363
415
  instrument_call(
@@ -368,29 +420,34 @@ module MCP
368
420
  result = case method
369
421
  when Methods::INITIALIZE
370
422
  init(params, session: session)
371
- when Methods::TOOLS_LIST
372
- { tools: @handlers[Methods::TOOLS_LIST].call(params) }
373
- when Methods::PROMPTS_LIST
374
- { prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
375
- when Methods::RESOURCES_LIST
376
- { resources: @handlers[Methods::RESOURCES_LIST].call(params) }
377
423
  when Methods::RESOURCES_READ
378
- { contents: @handlers[Methods::RESOURCES_READ].call(params) }
379
- when Methods::RESOURCES_TEMPLATES_LIST
380
- { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
424
+ { contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) }
425
+ when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
426
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
427
+ {}
381
428
  when Methods::TOOLS_CALL
382
- call_tool(params, session: session, related_request_id: related_request_id)
429
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
430
+ when Methods::PROMPTS_GET
431
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
383
432
  when Methods::COMPLETION_COMPLETE
384
- complete(params)
433
+ complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
385
434
  when Methods::LOGGING_SET_LEVEL
386
435
  configure_logging_level(params, session: session)
387
436
  else
388
- @handlers[method].call(params)
437
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
389
438
  end
390
439
  client = session&.client || @client
391
440
  add_instrumentation_data(client: client) if client
392
441
 
442
+ if cancellation&.cancelled?
443
+ add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
444
+ next JsonRpcHandler::NO_RESPONSE
445
+ end
446
+
393
447
  result
448
+ rescue CancelledError => e
449
+ add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
450
+ next JsonRpcHandler::NO_RESPONSE
394
451
  rescue RequestHandlerError => e
395
452
  report_exception(e.original_error || e, { request: request })
396
453
  add_instrumentation_data(error: e.error_type)
@@ -402,10 +459,23 @@ module MCP
402
459
  wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
403
460
  reported_exception = wrapped
404
461
  raise wrapped
462
+ ensure
463
+ session&.unregister_in_flight(related_request_id) if related_request_id
405
464
  end
406
465
  }
407
466
  end
408
467
 
468
+ def handle_cancelled_notification(params, session: nil)
469
+ return unless session
470
+ return unless params.is_a?(Hash)
471
+
472
+ request_id = params[:requestId] || params["requestId"]
473
+ return if request_id.nil?
474
+
475
+ reason = params[:reason] || params["reason"]
476
+ session.cancel_incoming(request_id: request_id, reason: reason)
477
+ end
478
+
409
479
  def default_capabilities
410
480
  {
411
481
  tools: { listChanged: true },
@@ -479,10 +549,12 @@ module MCP
479
549
  end
480
550
 
481
551
  def list_tools(request)
482
- @tools.values.map(&:to_h)
552
+ page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
553
+
554
+ { tools: page[:items], nextCursor: page[:next_cursor] }.compact
483
555
  end
484
556
 
485
- def call_tool(request, session: nil, related_request_id: nil)
557
+ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
486
558
  tool_name = request[:name]
487
559
 
488
560
  tool = tools[tool_name]
@@ -499,7 +571,7 @@ module MCP
499
571
  add_instrumentation_data(error: :missing_required_arguments)
500
572
 
501
573
  missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
502
- raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params)
574
+ return error_tool_response("Missing required arguments: #{missing}")
503
575
  end
504
576
 
505
577
  if configuration.validate_tool_call_arguments && tool.input_schema
@@ -508,14 +580,18 @@ module MCP
508
580
  rescue Tool::InputSchema::ValidationError => e
509
581
  add_instrumentation_data(error: :invalid_schema)
510
582
 
511
- raise RequestHandlerError.new(e.message, request, error_type: :invalid_params)
583
+ return error_tool_response(e.message)
512
584
  end
513
585
  end
514
586
 
515
587
  progress_token = request.dig(:_meta, :progressToken)
516
588
 
517
- call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id)
518
- rescue RequestHandlerError
589
+ call_tool_with_args(
590
+ tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
591
+ )
592
+ rescue RequestHandlerError, CancelledError
593
+ # CancelledError is intentionally not wrapped so `handle_request` can turn it into
594
+ # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
519
595
  raise
520
596
  rescue => e
521
597
  raise RequestHandlerError.new(
@@ -527,10 +603,12 @@ module MCP
527
603
  end
528
604
 
529
605
  def list_prompts(request)
530
- @prompts.values.map(&:to_h)
606
+ page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
607
+
608
+ { prompts: page[:items], nextCursor: page[:next_cursor] }.compact
531
609
  end
532
610
 
533
- def get_prompt(request)
611
+ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
534
612
  prompt_name = request[:name]
535
613
  prompt = @prompts[prompt_name]
536
614
  unless prompt
@@ -543,11 +621,20 @@ module MCP
543
621
  prompt_args = request[:arguments]
544
622
  prompt.validate_arguments!(prompt_args)
545
623
 
546
- call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
624
+ server_context = build_server_context(
625
+ request: request,
626
+ session: session,
627
+ related_request_id: related_request_id,
628
+ cancellation: cancellation,
629
+ )
630
+
631
+ call_prompt_template_with_args(prompt, prompt_args, server_context)
547
632
  end
548
633
 
549
634
  def list_resources(request)
550
- @resources.map(&:to_h)
635
+ page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
636
+
637
+ { resources: page[:items], nextCursor: page[:next_cursor] }.compact
551
638
  end
552
639
 
553
640
  # Server implementation should set `resources_read_handler` to override no-op default
@@ -557,17 +644,87 @@ module MCP
557
644
  end
558
645
 
559
646
  def list_resource_templates(request)
560
- @resource_templates.map(&:to_h)
647
+ page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
648
+
649
+ { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
561
650
  end
562
651
 
563
- def complete(params)
652
+ def complete(params, session: nil, related_request_id: nil, cancellation: nil)
564
653
  validate_completion_params!(params)
565
654
 
566
- result = @handlers[Methods::COMPLETION_COMPLETE].call(params)
655
+ result = dispatch_optional_context_handler(
656
+ @handlers[Methods::COMPLETION_COMPLETE],
657
+ params,
658
+ session: session,
659
+ related_request_id: related_request_id,
660
+ cancellation: cancellation,
661
+ )
567
662
 
568
663
  normalize_completion_result(result)
569
664
  end
570
665
 
666
+ # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
667
+ # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
668
+ # `server_context.raise_if_cancelled!`.
669
+ def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
670
+ dispatch_optional_context_handler(
671
+ @handlers[Methods::RESOURCES_READ],
672
+ request,
673
+ session: session,
674
+ related_request_id: related_request_id,
675
+ cancellation: cancellation,
676
+ )
677
+ end
678
+
679
+ # Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
680
+ # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
681
+ # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
682
+ # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
683
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
684
+ return handler.call(params) unless handler_declares_server_context?(handler)
685
+
686
+ server_context = build_server_context(
687
+ request: params,
688
+ session: session,
689
+ related_request_id: related_request_id,
690
+ cancellation: cancellation,
691
+ )
692
+ handler.call(params, server_context: server_context)
693
+ end
694
+
695
+ # Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter
696
+ # (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT
697
+ # treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}`
698
+ # Hash as the handler's second positional argument, which is never what the user meant.
699
+ #
700
+ # `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here,
701
+ # because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept
702
+ # that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`).
703
+ # Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked
704
+ # via `tool.call(**args, server_context: …)` without a positional argument.
705
+ def handler_declares_server_context?(handler)
706
+ return false unless handler.respond_to?(:parameters)
707
+
708
+ handler.parameters.any? do |type, name|
709
+ name == :server_context && (type == :key || type == :keyreq)
710
+ end
711
+ end
712
+
713
+ # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
714
+ # (progress, cancellation, nested server-to-client requests).
715
+ def build_server_context(request:, session:, related_request_id:, cancellation:)
716
+ meta_source = request.is_a?(Hash) ? request : {}
717
+ progress_token = meta_source.dig(:_meta, :progressToken)
718
+ progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
719
+ ServerContext.new(
720
+ server_context_with_meta(meta_source),
721
+ progress: progress,
722
+ notification_target: session,
723
+ related_request_id: related_request_id,
724
+ cancellation: cancellation,
725
+ )
726
+ end
727
+
571
728
  def report_exception(exception, server_context = {})
572
729
  configuration.exception_reporter.call(exception, server_context)
573
730
  end
@@ -588,18 +745,32 @@ module MCP
588
745
  ).to_h
589
746
  end
590
747
 
748
+ # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
749
+ # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
750
+ # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
751
+ # Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that
752
+ # happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword,
753
+ # and a positional slot would receive the `{server_context: ctx}` Hash instead.
591
754
  def accepts_server_context?(method_object)
592
755
  parameters = method_object.parameters
593
756
 
594
- parameters.any? { |type, name| type == :keyrest || name == :server_context }
757
+ parameters.any? do |type, name|
758
+ type == :keyrest || (name == :server_context && (type == :key || type == :keyreq))
759
+ end
595
760
  end
596
761
 
597
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
762
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
598
763
  args = arguments&.transform_keys(&:to_sym) || {}
599
764
 
600
765
  if accepts_server_context?(tool.method(:call))
601
766
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
602
- server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id)
767
+ server_context = ServerContext.new(
768
+ context,
769
+ progress: progress,
770
+ notification_target: session,
771
+ related_request_id: related_request_id,
772
+ cancellation: cancellation,
773
+ )
603
774
  tool.call(**args, server_context: server_context).to_h
604
775
  else
605
776
  tool.call(**args).to_h
@@ -2,11 +2,22 @@
2
2
 
3
3
  module MCP
4
4
  class ServerContext
5
- def initialize(context, progress:, notification_target:, related_request_id: nil)
5
+ attr_reader :cancellation
6
+
7
+ def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
6
8
  @context = context
7
9
  @progress = progress
8
10
  @notification_target = notification_target
9
11
  @related_request_id = related_request_id
12
+ @cancellation = cancellation
13
+ end
14
+
15
+ def cancelled?
16
+ !!@cancellation&.cancelled?
17
+ end
18
+
19
+ def raise_if_cancelled!
20
+ @cancellation&.raise_if_cancelled!
10
21
  end
11
22
 
12
23
  # Reports progress for the current tool operation.
@@ -30,6 +41,24 @@ module MCP
30
41
  @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
31
42
  end
32
43
 
44
+ # Sends a resource updated notification scoped to the originating session.
45
+ #
46
+ # @param uri [String] The URI of the updated resource.
47
+ def notify_resources_updated(uri:)
48
+ return unless @notification_target
49
+
50
+ @notification_target.notify_resources_updated(uri: uri)
51
+ end
52
+
53
+ # Delegates to the session so the request is scoped to the originating client.
54
+ def list_roots
55
+ if @notification_target.respond_to?(:list_roots)
56
+ @notification_target.list_roots(related_request_id: @related_request_id)
57
+ else
58
+ raise NoMethodError, "undefined method 'list_roots' for #{self}"
59
+ end
60
+ end
61
+
33
62
  # Delegates to the session so the request is scoped to the originating client.
34
63
  # Falls back to `@context` (via `method_missing`) when `@notification_target`
35
64
  # does not support sampling.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cancellation"
3
4
  require_relative "methods"
4
5
 
5
6
  module MCP
@@ -15,6 +16,48 @@ module MCP
15
16
  @client = nil
16
17
  @client_capabilities = nil
17
18
  @logging_message_notification = nil
19
+ @in_flight = {}
20
+ @in_flight_mutex = Mutex.new
21
+ end
22
+
23
+ # Registers a `Cancellation` token for an in-flight request.
24
+ def register_in_flight(request_id)
25
+ return if request_id.nil?
26
+
27
+ cancellation = Cancellation.new(request_id: request_id)
28
+ @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation }
29
+ cancellation
30
+ end
31
+
32
+ def unregister_in_flight(request_id)
33
+ return if request_id.nil?
34
+
35
+ @in_flight_mutex.synchronize { @in_flight.delete(request_id) }
36
+ end
37
+
38
+ def lookup_in_flight(request_id)
39
+ @in_flight_mutex.synchronize { @in_flight[request_id] }
40
+ end
41
+
42
+ # Flips the `Cancellation` for a matching in-flight request received from the peer.
43
+ # Silently ignores unknown IDs per MCP spec (cancellation utilities, item 5).
44
+ def cancel_incoming(request_id:, reason: nil)
45
+ cancellation = lookup_in_flight(request_id)
46
+ cancellation&.cancel(reason: reason)
47
+ end
48
+
49
+ # Sends `notifications/cancelled` to the peer for a previously-issued request.
50
+ # Also unblocks any transport-level `send_request` waiting on a response for `request_id`.
51
+ def cancel_request(request_id:, reason: nil)
52
+ params = { requestId: request_id }
53
+ params[:reason] = reason if reason
54
+ send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params)
55
+
56
+ if @transport.respond_to?(:cancel_pending_request)
57
+ @transport.cancel_pending_request(request_id, reason: reason)
58
+ end
59
+ rescue => e
60
+ MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id })
18
61
  end
19
62
 
20
63
  def handle(request)
@@ -41,6 +84,15 @@ module MCP
41
84
  @client_capabilities || @server.client_capabilities
42
85
  end
43
86
 
87
+ # Sends a `roots/list` request scoped to this session.
88
+ def list_roots(related_request_id: nil)
89
+ unless client_capabilities&.dig(:roots)
90
+ raise "Client does not support roots."
91
+ end
92
+
93
+ send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
94
+ end
95
+
44
96
  # Sends a `sampling/createMessage` request scoped to this session.
45
97
  def create_sampling_message(related_request_id: nil, **kwargs)
46
98
  params = @server.build_sampling_params(client_capabilities, **kwargs)
@@ -69,6 +121,23 @@ module MCP
69
121
  send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
70
122
  end
71
123
 
124
+ # Sends `notifications/cancelled` to the peer for a nested server-to-client request
125
+ # that was started inside a now-cancelled parent request. `related_request_id`
126
+ # is the parent request id so the notification is routed to the same stream
127
+ # (e.g. the parent's POST response stream on `StreamableHTTPTransport`) rather than
128
+ # the GET SSE stream.
129
+ def send_peer_cancellation(nested_request_id:, related_request_id: nil, reason: nil)
130
+ params = { requestId: nested_request_id }
131
+ params[:reason] = reason if reason
132
+ send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params, related_request_id: related_request_id)
133
+
134
+ if @transport.respond_to?(:cancel_pending_request)
135
+ @transport.cancel_pending_request(nested_request_id, reason: reason)
136
+ end
137
+ rescue => e
138
+ MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: nested_request_id })
139
+ end
140
+
72
141
  # Sends an elicitation complete notification scoped to this session.
73
142
  def notify_elicitation_complete(elicitation_id:)
74
143
  send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
@@ -76,6 +145,13 @@ module MCP
76
145
  @server.report_exception(e, notification: "elicitation_complete")
77
146
  end
78
147
 
148
+ # Sends a resource updated notification to this session only.
149
+ def notify_resources_updated(uri:)
150
+ send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
151
+ rescue => e
152
+ @server.report_exception(e, notification: "resources_updated")
153
+ end
154
+
79
155
  # Sends a progress notification to this session only.
80
156
  def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
81
157
  params = {
@@ -105,32 +181,57 @@ module MCP
105
181
 
106
182
  private
107
183
 
108
- # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
109
- # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
110
- #
111
- # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
112
- # `@transport.send_notification(method, params, session_id: @session_id)` and
113
- # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
184
+ # Forwards `send_notification` to the transport with only the kwargs the transport's method signature
185
+ # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
186
+ # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
187
+ # receive the session-scoped routing information.
114
188
  def send_to_transport(method, params, related_request_id: nil)
115
- if @session_id
116
- @transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id)
117
- else
118
- @transport.send_notification(method, params)
119
- end
189
+ kwargs = {
190
+ session_id: @session_id,
191
+ related_request_id: related_request_id,
192
+ }.compact
193
+
194
+ forward_to_transport(@transport.method(:send_notification), method, params, kwargs)
120
195
  end
121
196
 
122
- # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
123
- # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
124
- #
125
- # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
126
- # `@transport.send_request(method, params, session_id: @session_id)` and
127
- # add `**` to `Transport#send_request` and `StdioTransport#send_request`.
197
+ # Forwards `send_request` to the transport with only the kwargs the transport's method signature
198
+ # actually accepts. Custom transports that implement the abstract `send_request(method, params = nil)`
199
+ # contract continue to work; bundled transports that declare `session_id:` / `related_request_id:` /
200
+ # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing.
201
+ # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up
202
+ # so that cancelling the parent also cancels this nested server-to-client request.
128
203
  def send_to_transport_request(method, params, related_request_id: nil)
129
- if @session_id
130
- @transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id)
204
+ parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil
205
+
206
+ kwargs = {
207
+ session_id: @session_id,
208
+ related_request_id: related_request_id,
209
+ parent_cancellation: parent_cancellation,
210
+ server_session: self,
211
+ }.compact
212
+
213
+ forward_to_transport(@transport.method(:send_request), method, params, kwargs)
214
+ end
215
+
216
+ # Calls `transport_method(method, params, **supported)` where `supported` contains only the keys
217
+ # the transport's method signature accepts. This keeps bundled transports (which declare the new kwargs)
218
+ # working while preserving compatibility with custom transports that implement only the abstract
219
+ # `(method, params = nil)` contract.
220
+ def forward_to_transport(transport_method, method, params, kwargs)
221
+ parameters = transport_method.parameters
222
+ accepts_keyrest = parameters.any? { |type, _| type == :keyrest }
223
+ supported = if accepts_keyrest
224
+ kwargs
131
225
  else
132
- @transport.send_request(method, params)
226
+ allowed = parameters.filter_map { |type, name| name if type == :key || type == :keyreq }
227
+ kwargs.slice(*allowed)
133
228
  end
229
+
230
+ # Always splat `**supported` even when empty: on Ruby 2.7 the bare `transport_method.call(method, params)`
231
+ # form would let the trailing `params` Hash be auto-promoted to keyword arguments when the receiver
232
+ # accepts `**kwargs`, breaking handlers that rely on `params` arriving as a positional Hash.
233
+ # The explicit splat suppresses that conversion and is a no-op when `supported` is empty.
234
+ transport_method.call(method, params, **supported)
134
235
  end
135
236
  end
136
237
  end
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.13.0"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/mcp.rb CHANGED
@@ -8,6 +8,8 @@ require_relative "mcp/version"
8
8
 
9
9
  module MCP
10
10
  autoload :Annotations, "mcp/annotations"
11
+ autoload :Cancellation, "mcp/cancellation"
12
+ autoload :CancelledError, "mcp/cancelled_error"
11
13
  autoload :Client, "mcp/client"
12
14
  autoload :Content, "mcp/content"
13
15
  autoload :Icon, "mcp/icon"