mcp 0.10.0 → 0.14.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
@@ -6,6 +6,7 @@ require_relative "methods"
6
6
  require_relative "logging_message_notification"
7
7
  require_relative "progress"
8
8
  require_relative "server_context"
9
+ require_relative "server/pagination"
9
10
  require_relative "server/transports"
10
11
 
11
12
  module MCP
@@ -24,15 +25,34 @@ module MCP
24
25
  UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze
25
26
  UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze
26
27
 
28
+ DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze
29
+
30
+ # Servers return an array of completion values ranked by relevance, with maximum 100 items per response.
31
+ # https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results
32
+ MAX_COMPLETION_VALUES = 100
33
+
27
34
  class RequestHandlerError < StandardError
28
- attr_reader :error_type
29
- attr_reader :original_error
35
+ attr_reader :error_type, :original_error, :error_code, :error_data
30
36
 
31
- def initialize(message, request, error_type: :internal_error, original_error: nil)
37
+ def initialize(message, request, error_type: :internal_error, original_error: nil, error_code: nil, error_data: nil)
32
38
  super(message)
33
39
  @request = request
34
40
  @error_type = error_type
35
41
  @original_error = original_error
42
+ @error_code = error_code
43
+ @error_data = error_data
44
+ end
45
+ end
46
+
47
+ class URLElicitationRequiredError < RequestHandlerError
48
+ def initialize(elicitations)
49
+ super(
50
+ "URL elicitation required",
51
+ nil,
52
+ error_type: :url_elicitation_required,
53
+ error_code: -32042,
54
+ error_data: { elicitations: elicitations },
55
+ )
36
56
  end
37
57
  end
38
58
 
@@ -46,8 +66,10 @@ module MCP
46
66
  end
47
67
 
48
68
  include Instrumentation
69
+ include Pagination
49
70
 
50
71
  attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
72
+ attr_reader :page_size, :client_capabilities
51
73
 
52
74
  def initialize(
53
75
  description: nil,
@@ -64,6 +86,7 @@ module MCP
64
86
  server_context: nil,
65
87
  configuration: nil,
66
88
  capabilities: nil,
89
+ page_size: nil,
67
90
  transport: nil
68
91
  )
69
92
  @description = description
@@ -80,18 +103,22 @@ module MCP
80
103
  @resource_templates = resource_templates
81
104
  @resource_index = index_resources_by_uri(resources)
82
105
  @server_context = server_context
106
+ self.page_size = page_size
83
107
  @configuration = MCP.configuration.merge(configuration)
84
108
  @client = nil
85
109
 
86
110
  validate!
87
111
 
88
112
  @capabilities = capabilities || default_capabilities
113
+ @client_capabilities = nil
89
114
  @logging_message_notification = nil
90
115
 
91
116
  @handlers = {
92
117
  Methods::RESOURCES_LIST => method(:list_resources),
93
118
  Methods::RESOURCES_READ => method(:read_resource_no_content),
94
119
  Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
120
+ Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
121
+ Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
95
122
  Methods::TOOLS_LIST => method(:list_tools),
96
123
  Methods::TOOLS_CALL => method(:call_tool),
97
124
  Methods::PROMPTS_LIST => method(:list_prompts),
@@ -100,13 +127,9 @@ module MCP
100
127
  Methods::PING => ->(_) { {} },
101
128
  Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
102
129
  Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
130
+ Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
131
+ Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
103
132
  Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
104
-
105
- # No op handlers for currently unsupported methods
106
- Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
107
- Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
108
- Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
109
- Methods::ELICITATION_CREATE => ->(_) {},
110
133
  }
111
134
  @transport = transport
112
135
  end
@@ -119,8 +142,8 @@ module MCP
119
142
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
120
143
  # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
121
144
  def handle(request, session: nil)
122
- JsonRpcHandler.handle(request) do |method|
123
- handle_request(request, method, session: session)
145
+ JsonRpcHandler.handle(request) do |method, request_id|
146
+ handle_request(request, method, session: session, related_request_id: request_id)
124
147
  end
125
148
  end
126
149
 
@@ -132,8 +155,8 @@ module MCP
132
155
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
133
156
  # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
134
157
  def handle_json(request, session: nil)
135
- JsonRpcHandler.handle_json(request) do |method|
136
- handle_request(request, method, session: session)
158
+ JsonRpcHandler.handle_json(request) do |method, request_id|
159
+ handle_request(request, method, session: session, related_request_id: request_id)
137
160
  end
138
161
  end
139
162
 
@@ -162,6 +185,14 @@ module MCP
162
185
  @handlers[method_name] = block
163
186
  end
164
187
 
188
+ def page_size=(page_size)
189
+ unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0)
190
+ raise ArgumentError, "page_size must be nil or a positive integer"
191
+ end
192
+
193
+ @page_size = page_size
194
+ end
195
+
165
196
  def notify_tools_list_changed
166
197
  return unless @transport
167
198
 
@@ -198,6 +229,14 @@ module MCP
198
229
  report_exception(e, { notification: "log_message" })
199
230
  end
200
231
 
232
+ # Sets a handler for `notifications/roots/list_changed` notifications.
233
+ # Called when a client notifies the server that its filesystem roots have changed.
234
+ #
235
+ # @yield [params] The notification params (typically `nil`).
236
+ def roots_list_changed_handler(&block)
237
+ @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
238
+ end
239
+
201
240
  # Sets a custom handler for `resources/read` requests.
202
241
  # The block receives the parsed request params and should return resource
203
242
  # contents. The return value is set as the `contents` field of the response.
@@ -208,6 +247,72 @@ module MCP
208
247
  @handlers[Methods::RESOURCES_READ] = block
209
248
  end
210
249
 
250
+ # Sets a custom handler for `completion/complete` requests.
251
+ # The block receives the parsed request params and should return completion values.
252
+ #
253
+ # @yield [params] The request params containing `:ref`, `:argument`, and optionally `:context`.
254
+ # @yieldreturn [Hash] A hash with `:completion` key containing `:values`, optional `:total`, and `:hasMore`.
255
+ def completion_handler(&block)
256
+ @handlers[Methods::COMPLETION_COMPLETE] = block
257
+ end
258
+
259
+ # Sets a custom handler for `resources/subscribe` requests.
260
+ # The block receives the parsed request params. The return value is
261
+ # ignored; the response is always an empty result `{}` per the MCP specification.
262
+ #
263
+ # @yield [params] The request params containing `:uri`.
264
+ def resources_subscribe_handler(&block)
265
+ @handlers[Methods::RESOURCES_SUBSCRIBE] = block
266
+ end
267
+
268
+ # Sets a custom handler for `resources/unsubscribe` requests.
269
+ # The block receives the parsed request params. The return value is
270
+ # ignored; the response is always an empty result `{}` per the MCP specification.
271
+ #
272
+ # @yield [params] The request params containing `:uri`.
273
+ def resources_unsubscribe_handler(&block)
274
+ @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
275
+ end
276
+
277
+ def build_sampling_params(
278
+ capabilities,
279
+ messages:,
280
+ max_tokens:,
281
+ system_prompt: nil,
282
+ model_preferences: nil,
283
+ include_context: nil,
284
+ temperature: nil,
285
+ stop_sequences: nil,
286
+ metadata: nil,
287
+ tools: nil,
288
+ tool_choice: nil
289
+ )
290
+ unless capabilities&.dig(:sampling)
291
+ raise "Client does not support sampling."
292
+ end
293
+
294
+ if tools && !capabilities.dig(:sampling, :tools)
295
+ raise "Client does not support sampling with tools."
296
+ end
297
+
298
+ if tool_choice && !capabilities.dig(:sampling, :tools)
299
+ raise "Client does not support sampling with tool_choice."
300
+ end
301
+
302
+ {
303
+ messages: messages,
304
+ maxTokens: max_tokens,
305
+ systemPrompt: system_prompt,
306
+ modelPreferences: model_preferences,
307
+ includeContext: include_context,
308
+ temperature: temperature,
309
+ stopSequences: stop_sequences,
310
+ metadata: metadata,
311
+ tools: tools,
312
+ toolChoice: tool_choice,
313
+ }.compact
314
+ end
315
+
211
316
  private
212
317
 
213
318
  def validate!
@@ -278,10 +383,10 @@ module MCP
278
383
  end
279
384
  end
280
385
 
281
- def handle_request(request, method, session: nil)
386
+ def handle_request(request, method, session: nil, related_request_id: nil)
282
387
  handler = @handlers[method]
283
388
  unless handler
284
- instrument_call("unsupported_method") do
389
+ instrument_call("unsupported_method", server_context: { request: request }) do
285
390
  client = session&.client || @client
286
391
  add_instrumentation_data(client: client) if client
287
392
  end
@@ -291,22 +396,24 @@ module MCP
291
396
  Methods.ensure_capability!(method, capabilities)
292
397
 
293
398
  ->(params) {
294
- instrument_call(method) do
399
+ reported_exception = nil
400
+ instrument_call(
401
+ method,
402
+ server_context: { request: request },
403
+ exception_already_reported: ->(e) { reported_exception.equal?(e) },
404
+ ) do
295
405
  result = case method
296
406
  when Methods::INITIALIZE
297
407
  init(params, session: session)
298
- when Methods::TOOLS_LIST
299
- { tools: @handlers[Methods::TOOLS_LIST].call(params) }
300
- when Methods::PROMPTS_LIST
301
- { prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
302
- when Methods::RESOURCES_LIST
303
- { resources: @handlers[Methods::RESOURCES_LIST].call(params) }
304
408
  when Methods::RESOURCES_READ
305
409
  { contents: @handlers[Methods::RESOURCES_READ].call(params) }
306
- when Methods::RESOURCES_TEMPLATES_LIST
307
- { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
410
+ when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
411
+ @handlers[method].call(params)
412
+ {}
308
413
  when Methods::TOOLS_CALL
309
- call_tool(params, session: session)
414
+ call_tool(params, session: session, related_request_id: related_request_id)
415
+ when Methods::COMPLETION_COMPLETE
416
+ complete(params)
310
417
  when Methods::LOGGING_SET_LEVEL
311
418
  configure_logging_level(params, session: session)
312
419
  else
@@ -316,15 +423,17 @@ module MCP
316
423
  add_instrumentation_data(client: client) if client
317
424
 
318
425
  result
426
+ rescue RequestHandlerError => e
427
+ report_exception(e.original_error || e, { request: request })
428
+ add_instrumentation_data(error: e.error_type)
429
+ reported_exception = e
430
+ raise e
319
431
  rescue => e
320
432
  report_exception(e, { request: request })
321
- if e.is_a?(RequestHandlerError)
322
- add_instrumentation_data(error: e.error_type)
323
- raise e
324
- end
325
-
326
433
  add_instrumentation_data(error: :internal_error)
327
- raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
434
+ wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
435
+ reported_exception = wrapped
436
+ raise wrapped
328
437
  end
329
438
  }
330
439
  end
@@ -355,10 +464,11 @@ module MCP
355
464
  session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
356
465
  else
357
466
  @client = params[:clientInfo]
467
+ @client_capabilities = params[:capabilities]
358
468
  end
469
+ protocol_version = params[:protocolVersion]
359
470
  end
360
471
 
361
- protocol_version = params[:protocolVersion] if params
362
472
  negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
363
473
  protocol_version
364
474
  else
@@ -401,10 +511,12 @@ module MCP
401
511
  end
402
512
 
403
513
  def list_tools(request)
404
- @tools.values.map(&:to_h)
514
+ page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
515
+
516
+ { tools: page[:items], nextCursor: page[:next_cursor] }.compact
405
517
  end
406
518
 
407
- def call_tool(request, session: nil)
519
+ def call_tool(request, session: nil, related_request_id: nil)
408
520
  tool_name = request[:name]
409
521
 
410
522
  tool = tools[tool_name]
@@ -421,7 +533,7 @@ module MCP
421
533
  add_instrumentation_data(error: :missing_required_arguments)
422
534
 
423
535
  missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
424
- return error_tool_response("Missing required arguments: #{missing}")
536
+ raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params)
425
537
  end
426
538
 
427
539
  if configuration.validate_tool_call_arguments && tool.input_schema
@@ -430,23 +542,28 @@ module MCP
430
542
  rescue Tool::InputSchema::ValidationError => e
431
543
  add_instrumentation_data(error: :invalid_schema)
432
544
 
433
- return error_tool_response(e.message)
545
+ raise RequestHandlerError.new(e.message, request, error_type: :invalid_params)
434
546
  end
435
547
  end
436
548
 
437
549
  progress_token = request.dig(:_meta, :progressToken)
438
550
 
439
- call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session)
551
+ call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id)
440
552
  rescue RequestHandlerError
441
553
  raise
442
554
  rescue => e
443
- report_exception(e, request: request)
444
-
445
- error_tool_response("Internal error calling tool #{tool_name}: #{e.message}")
555
+ raise RequestHandlerError.new(
556
+ "Internal error calling tool #{tool_name}: #{e.message}",
557
+ request,
558
+ error_type: :internal_error,
559
+ original_error: e,
560
+ )
446
561
  end
447
562
 
448
563
  def list_prompts(request)
449
- @prompts.values.map(&:to_h)
564
+ page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
565
+
566
+ { prompts: page[:items], nextCursor: page[:next_cursor] }.compact
450
567
  end
451
568
 
452
569
  def get_prompt(request)
@@ -466,7 +583,9 @@ module MCP
466
583
  end
467
584
 
468
585
  def list_resources(request)
469
- @resources.map(&:to_h)
586
+ page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
587
+
588
+ { resources: page[:items], nextCursor: page[:next_cursor] }.compact
470
589
  end
471
590
 
472
591
  # Server implementation should set `resources_read_handler` to override no-op default
@@ -476,7 +595,17 @@ module MCP
476
595
  end
477
596
 
478
597
  def list_resource_templates(request)
479
- @resource_templates.map(&:to_h)
598
+ page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
599
+
600
+ { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
601
+ end
602
+
603
+ def complete(params)
604
+ validate_completion_params!(params)
605
+
606
+ result = @handlers[Methods::COMPLETION_COMPLETE].call(params)
607
+
608
+ normalize_completion_result(result)
480
609
  end
481
610
 
482
611
  def report_exception(exception, server_context = {})
@@ -505,12 +634,12 @@ module MCP
505
634
  parameters.any? { |type, name| type == :keyrest || name == :server_context }
506
635
  end
507
636
 
508
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
637
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
509
638
  args = arguments&.transform_keys(&:to_sym) || {}
510
639
 
511
640
  if accepts_server_context?(tool.method(:call))
512
- progress = Progress.new(notification_target: session, progress_token: progress_token)
513
- server_context = ServerContext.new(context, progress: progress, notification_target: session)
641
+ progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
642
+ server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id)
514
643
  tool.call(**args, server_context: server_context).to_h
515
644
  else
516
645
  tool.call(**args).to_h
@@ -537,5 +666,56 @@ module MCP
537
666
  server_context
538
667
  end
539
668
  end
669
+
670
+ def validate_completion_params!(params)
671
+ unless params.is_a?(Hash)
672
+ raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
673
+ end
674
+
675
+ ref = params[:ref]
676
+ if ref.nil? || ref[:type].nil?
677
+ raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params)
678
+ end
679
+
680
+ argument = params[:argument]
681
+ if argument.nil? || argument[:name].nil? || !argument.key?(:value)
682
+ raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params)
683
+ end
684
+
685
+ case ref[:type]
686
+ when "ref/prompt"
687
+ unless @prompts[ref[:name]]
688
+ raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params)
689
+ end
690
+ when "ref/resource"
691
+ uri = ref[:uri]
692
+ found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri }
693
+ unless found
694
+ raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params)
695
+ end
696
+ else
697
+ raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params)
698
+ end
699
+ end
700
+
701
+ def normalize_completion_result(result)
702
+ return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash)
703
+
704
+ completion = result[:completion] || result["completion"]
705
+ return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash)
706
+
707
+ values = completion[:values] || completion["values"] || []
708
+ total = completion[:total] || completion["total"]
709
+ has_more = completion[:hasMore] || completion["hasMore"] || false
710
+
711
+ count = values.length
712
+ if count > MAX_COMPLETION_VALUES
713
+ has_more = true
714
+ total ||= count
715
+ values = values.first(MAX_COMPLETION_VALUES)
716
+ end
717
+
718
+ { completion: { values: values, total: total, hasMore: has_more }.compact }
719
+ end
540
720
  end
541
721
  end
@@ -2,10 +2,11 @@
2
2
 
3
3
  module MCP
4
4
  class ServerContext
5
- def initialize(context, progress:, notification_target:)
5
+ def initialize(context, progress:, notification_target:, related_request_id: nil)
6
6
  @context = context
7
7
  @progress = progress
8
8
  @notification_target = notification_target
9
+ @related_request_id = related_request_id
9
10
  end
10
11
 
11
12
  # Reports progress for the current tool operation.
@@ -26,7 +27,74 @@ module MCP
26
27
  def notify_log_message(data:, level:, logger: nil)
27
28
  return unless @notification_target
28
29
 
29
- @notification_target.notify_log_message(data: data, level: level, logger: logger)
30
+ @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
31
+ end
32
+
33
+ # Sends a resource updated notification scoped to the originating session.
34
+ #
35
+ # @param uri [String] The URI of the updated resource.
36
+ def notify_resources_updated(uri:)
37
+ return unless @notification_target
38
+
39
+ @notification_target.notify_resources_updated(uri: uri)
40
+ end
41
+
42
+ # Delegates to the session so the request is scoped to the originating client.
43
+ def list_roots
44
+ if @notification_target.respond_to?(:list_roots)
45
+ @notification_target.list_roots(related_request_id: @related_request_id)
46
+ else
47
+ raise NoMethodError, "undefined method 'list_roots' for #{self}"
48
+ end
49
+ end
50
+
51
+ # Delegates to the session so the request is scoped to the originating client.
52
+ # Falls back to `@context` (via `method_missing`) when `@notification_target`
53
+ # does not support sampling.
54
+ def create_sampling_message(**kwargs)
55
+ if @notification_target.respond_to?(:create_sampling_message)
56
+ @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
57
+ elsif @context.respond_to?(:create_sampling_message)
58
+ @context.create_sampling_message(**kwargs, related_request_id: @related_request_id)
59
+ else
60
+ raise NoMethodError, "undefined method 'create_sampling_message' for #{self}"
61
+ end
62
+ end
63
+
64
+ # Delegates to the session so the request is scoped to the originating client.
65
+ # Falls back to `@context` (via `method_missing`) when `@notification_target`
66
+ # does not support elicitation.
67
+ def create_form_elicitation(**kwargs)
68
+ if @notification_target.respond_to?(:create_form_elicitation)
69
+ @notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
70
+ elsif @context.respond_to?(:create_form_elicitation)
71
+ @context.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
72
+ else
73
+ raise NoMethodError, "undefined method 'create_form_elicitation' for #{self}"
74
+ end
75
+ end
76
+
77
+ # Delegates to the session so the request is scoped to the originating client.
78
+ # Falls back to `@context` when `@notification_target` does not support URL mode elicitation.
79
+ def create_url_elicitation(**kwargs)
80
+ if @notification_target.respond_to?(:create_url_elicitation)
81
+ @notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
82
+ elsif @context.respond_to?(:create_url_elicitation)
83
+ @context.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
84
+ else
85
+ raise NoMethodError, "undefined method 'create_url_elicitation' for #{self}"
86
+ end
87
+ end
88
+
89
+ # Delegates to the session so the notification is scoped to the originating client.
90
+ def notify_elicitation_complete(**kwargs)
91
+ if @notification_target.respond_to?(:notify_elicitation_complete)
92
+ @notification_target.notify_elicitation_complete(**kwargs)
93
+ elsif @context.respond_to?(:notify_elicitation_complete)
94
+ @context.notify_elicitation_complete(**kwargs)
95
+ else
96
+ raise NoMethodError, "undefined method 'notify_elicitation_complete' for #{self}"
97
+ end
30
98
  end
31
99
 
32
100
  def method_missing(name, ...)
@@ -13,7 +13,7 @@ module MCP
13
13
  @transport = transport
14
14
  @session_id = session_id
15
15
  @client = nil
16
- @client_capabilities = nil # TODO: Use for per-session capability validation.
16
+ @client_capabilities = nil
17
17
  @logging_message_notification = nil
18
18
  end
19
19
 
@@ -36,8 +36,64 @@ module MCP
36
36
  @logging_message_notification = logging_message_notification
37
37
  end
38
38
 
39
+ # Returns per-session client capabilities, falling back to global.
40
+ def client_capabilities
41
+ @client_capabilities || @server.client_capabilities
42
+ end
43
+
44
+ # Sends a `roots/list` request scoped to this session.
45
+ def list_roots(related_request_id: nil)
46
+ unless client_capabilities&.dig(:roots)
47
+ raise "Client does not support roots."
48
+ end
49
+
50
+ send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
51
+ end
52
+
53
+ # Sends a `sampling/createMessage` request scoped to this session.
54
+ def create_sampling_message(related_request_id: nil, **kwargs)
55
+ params = @server.build_sampling_params(client_capabilities, **kwargs)
56
+ send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
57
+ end
58
+
59
+ # Sends an `elicitation/create` request (form mode) scoped to this session.
60
+ def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
61
+ unless client_capabilities&.dig(:elicitation)
62
+ raise "Client does not support elicitation. " \
63
+ "The client must declare the `elicitation` capability during initialization."
64
+ end
65
+
66
+ params = { mode: "form", message: message, requestedSchema: requested_schema }
67
+ send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
68
+ end
69
+
70
+ # Sends an `elicitation/create` request (URL mode) scoped to this session.
71
+ def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
72
+ unless client_capabilities&.dig(:elicitation, :url)
73
+ raise "Client does not support URL mode elicitation. " \
74
+ "The client must declare the `elicitation.url` capability during initialization."
75
+ end
76
+
77
+ params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
78
+ send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
79
+ end
80
+
81
+ # Sends an elicitation complete notification scoped to this session.
82
+ def notify_elicitation_complete(elicitation_id:)
83
+ send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
84
+ rescue => e
85
+ @server.report_exception(e, notification: "elicitation_complete")
86
+ end
87
+
88
+ # Sends a resource updated notification to this session only.
89
+ def notify_resources_updated(uri:)
90
+ send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
91
+ rescue => e
92
+ @server.report_exception(e, notification: "resources_updated")
93
+ end
94
+
39
95
  # Sends a progress notification to this session only.
40
- def notify_progress(progress_token:, progress:, total: nil, message: nil)
96
+ def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
41
97
  params = {
42
98
  "progressToken" => progress_token,
43
99
  "progress" => progress,
@@ -45,35 +101,52 @@ module MCP
45
101
  "message" => message,
46
102
  }.compact
47
103
 
48
- send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params)
104
+ send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
49
105
  rescue => e
50
106
  @server.report_exception(e, notification: "progress")
51
107
  end
52
108
 
53
109
  # Sends a log message notification to this session only.
54
- def notify_log_message(data:, level:, logger: nil)
110
+ def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
55
111
  effective_logging = @logging_message_notification || @server.logging_message_notification
56
112
  return unless effective_logging&.should_notify?(level)
57
113
 
58
114
  params = { "data" => data, "level" => level }
59
115
  params["logger"] = logger if logger
60
116
 
61
- send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params)
117
+ send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
62
118
  rescue => e
63
119
  @server.report_exception(e, { notification: "log_message" })
64
120
  end
65
121
 
66
122
  private
67
123
 
124
+ # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
125
+ # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
126
+ #
68
127
  # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
69
128
  # `@transport.send_notification(method, params, session_id: @session_id)` and
70
129
  # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
71
- def send_to_transport(method, params)
130
+ def send_to_transport(method, params, related_request_id: nil)
72
131
  if @session_id
73
- @transport.send_notification(method, params, session_id: @session_id)
132
+ @transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id)
74
133
  else
75
134
  @transport.send_notification(method, params)
76
135
  end
77
136
  end
137
+
138
+ # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
139
+ # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
140
+ #
141
+ # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
142
+ # `@transport.send_request(method, params, session_id: @session_id)` and
143
+ # add `**` to `Transport#send_request` and `StdioTransport#send_request`.
144
+ def send_to_transport_request(method, params, related_request_id: nil)
145
+ if @session_id
146
+ @transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id)
147
+ else
148
+ @transport.send_request(method, params)
149
+ end
150
+ end
78
151
  end
79
152
  end