ruby-mcp-client 1.1.0 → 2.0.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.
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'logger'
4
+ require 'securerandom'
4
5
 
5
6
  module MCPClient
6
7
  # MCP Client for integrating with the Model Context Protocol
7
8
  # This is the main entry point for using MCP tools
8
9
  class Client
10
+ # Elicitation modes implemented by this client (MCP 2025-11-25).
11
+ # Requests with a mode outside this set are rejected with -32602.
12
+ SUPPORTED_ELICITATION_MODES = %w[form url].freeze
13
+
9
14
  # @!attribute [r] servers
10
15
  # @return [Array<MCPClient::ServerBase>] list of servers
11
16
  # @!attribute [r] tool_cache
@@ -20,13 +25,33 @@ module MCPClient
20
25
  # @return [Array<MCPClient::Root>] list of MCP roots (MCP 2025-06-18)
21
26
  attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots
22
27
 
28
+ # Supported modes for structuredContent validation (MCP 2025-11-25):
29
+ # :warn logs a warning on mismatch, :strict raises a ValidationError.
30
+ STRUCTURED_CONTENT_MODES = %i[warn strict].freeze
31
+
23
32
  # Initialize a new MCPClient::Client
24
33
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
25
34
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
26
35
  # @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
27
36
  # @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
28
37
  # @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25)
29
- def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
38
+ # @param sampling_supports_tools [Boolean] whether the sampling handler supports tool use
39
+ # (MCP 2025-11-25 / SEP-1577); declares the sampling.tools capability and forwards
40
+ # tools/toolChoice params to the handler instead of rejecting tool-enabled requests
41
+ # @param client_info [Hash, nil] host-provided Implementation info sent as clientInfo
42
+ # (name and version required; title, description, websiteUrl, icons optional)
43
+ # @param validate_structured_content [Symbol] how to treat a tools/call result whose
44
+ # structuredContent does not match the tool's declared outputSchema (MCP 2025-11-25:
45
+ # "Clients SHOULD validate structured results against this schema"): :warn (default)
46
+ # logs a warning, :strict raises MCPClient::Errors::ValidationError
47
+ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil,
48
+ sampling_supports_tools: false, client_info: nil, validate_structured_content: :warn)
49
+ unless STRUCTURED_CONTENT_MODES.include?(validate_structured_content)
50
+ raise ArgumentError, "validate_structured_content must be one of #{STRUCTURED_CONTENT_MODES.inspect}, " \
51
+ "got #{validate_structured_content.inspect}"
52
+ end
53
+
54
+ @validate_structured_content = validate_structured_content
30
55
  # Preserve a caller-supplied logger's formatter (only tag progname), and
31
56
  # install the default formatter solely on a logger we create ourselves.
32
57
  # Overwriting the formatter of an application's logger would silently
@@ -44,6 +69,9 @@ module MCPClient
44
69
  MCPClient::ServerFactory.create(config, logger: @logger)
45
70
  end
46
71
  @tool_cache = {}
72
+ # Active progressToken -> callback registrations (MCP progress utility)
73
+ @progress_callbacks = {}
74
+ @progress_mutex = Mutex.new
47
75
  @prompt_cache = {}
48
76
  @resource_cache = {}
49
77
  # JSON-RPC notification listeners
@@ -52,24 +80,37 @@ module MCPClient
52
80
  @elicitation_handler = elicitation_handler
53
81
  # Sampling handler (MCP 2025-11-25)
54
82
  @sampling_handler = sampling_handler
83
+ # Whether the sampling handler supports tool use (SEP-1577)
84
+ @sampling_supports_tools = sampling_supports_tools
55
85
  # Roots (MCP 2025-06-18)
56
86
  @roots = normalize_roots(roots)
57
87
  # Register default and user-defined notification handlers on each server
58
88
  @servers.each do |server|
89
+ # Host-provided Implementation info for the initialize clientInfo
90
+ server.client_info = client_info if client_info && server.respond_to?(:client_info=)
59
91
  server.on_notification do |method, params|
60
92
  # Default notification processing (e.g., cache invalidation, logging)
61
93
  process_notification(server, method, params)
62
94
  # Invoke user-defined listeners
63
95
  @notification_listeners.each { |cb| cb.call(server, method, params) }
64
96
  end
65
- # Register elicitation handler on each server
66
- if server.respond_to?(:on_elicitation_request)
97
+ # Register feature callbacks only for features the host actually
98
+ # supports: transports derive their declared client capabilities from
99
+ # the callbacks registered before connecting, and MCP forbids using
100
+ # capabilities that were not negotiated.
101
+ if @elicitation_handler && server.respond_to?(:on_elicitation_request)
67
102
  server.on_elicitation_request(&method(:handle_elicitation_request))
68
103
  end
69
- # Register roots list handler on each server (MCP 2025-06-18)
104
+ # The client always implements the roots feature (roots/list and
105
+ # list_changed notifications), independent of the current roots list.
70
106
  server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
71
- # Register sampling handler on each server (MCP 2025-11-25)
72
- server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
107
+ next unless @sampling_handler && server.respond_to?(:on_sampling_request)
108
+
109
+ server.on_sampling_request(&method(:handle_sampling_request))
110
+ # Declare the sampling.tools sub-capability (SEP-1577) only when the
111
+ # host opted in; the transport derives its initialize declaration
112
+ # from this before connecting.
113
+ server.declare_sampling_tools if @sampling_supports_tools && server.respond_to?(:declare_sampling_tools)
73
114
  end
74
115
  end
75
116
 
@@ -259,34 +300,8 @@ module MCPClient
259
300
  # @param parameters [Hash] the parameters to pass to the tool
260
301
  # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
261
302
  # @return [Object] the result of the tool invocation
262
- def call_tool(tool_name, parameters, server: nil)
263
- tools = list_tools
264
-
265
- if server
266
- # Use the specified server
267
- srv = select_server(server)
268
- # Find the tool on this specific server
269
- tool = tools.find { |t| t.name == tool_name && t.server == srv }
270
- unless tool
271
- raise MCPClient::Errors::ToolNotFound,
272
- "Tool '#{tool_name}' not found on server '#{srv.name || srv.class.name}'"
273
- end
274
- else
275
- # Find the tool across all servers
276
- matching_tools = tools.select { |t| t.name == tool_name }
277
-
278
- if matching_tools.empty?
279
- raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found"
280
- elsif matching_tools.size > 1
281
- # If multiple matches, disambiguate with server names
282
- server_names = matching_tools.map { |t| t.server&.name || 'unnamed' }
283
- raise MCPClient::Errors::AmbiguousToolName,
284
- "Multiple tools named '#{tool_name}' found across servers (#{server_names.join(', ')}). " \
285
- "Please specify a server using the 'server' parameter."
286
- end
287
-
288
- tool = matching_tools.first
289
- end
303
+ def call_tool(tool_name, parameters, server: nil, progress: nil)
304
+ tool = resolve_tool(tool_name, server: server)
290
305
 
291
306
  # Validate parameters against tool schema
292
307
  validate_params!(tool, parameters)
@@ -296,13 +311,24 @@ module MCPClient
296
311
  server = tool.server
297
312
  raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
298
313
 
299
- begin
314
+ # MCP progress utility: attach an auto-generated progressToken to the
315
+ # request _meta and route matching notifications/progress to the
316
+ # caller's callback while the request is active.
317
+ parameters, token = setup_progress_tracking(parameters, progress)
318
+
319
+ result = begin
300
320
  server.call_tool(tool_name, parameters)
301
321
  rescue MCPClient::Errors::ConnectionError => e
302
322
  # Add server identity information to the error for better context
303
323
  server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
304
324
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message} (Server: #{server_id})"
325
+ ensure
326
+ # Tokens are only valid for the lifetime of the request: dropping the
327
+ # registration filters out stale post-completion notifications.
328
+ unregister_progress_callback(token) if token
305
329
  end
330
+
331
+ validate_structured_content!(tool, result)
306
332
  end
307
333
 
308
334
  # Convert MCP tools to OpenAI function specifications
@@ -406,33 +432,7 @@ module MCPClient
406
432
  # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
407
433
  # @return [Enumerator] streaming enumerator or single-value enumerator
408
434
  def call_tool_streaming(tool_name, parameters, server: nil)
409
- tools = list_tools
410
-
411
- if server
412
- # Use the specified server
413
- srv = select_server(server)
414
- # Find the tool on this specific server
415
- tool = tools.find { |t| t.name == tool_name && t.server == srv }
416
- unless tool
417
- raise MCPClient::Errors::ToolNotFound,
418
- "Tool '#{tool_name}' not found on server '#{srv.name || srv.class.name}'"
419
- end
420
- else
421
- # Find the tool across all servers
422
- matching_tools = tools.select { |t| t.name == tool_name }
423
-
424
- if matching_tools.empty?
425
- raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found"
426
- elsif matching_tools.size > 1
427
- # If multiple matches, disambiguate with server names
428
- server_names = matching_tools.map { |t| t.server&.name || 'unnamed' }
429
- raise MCPClient::Errors::AmbiguousToolName,
430
- "Multiple tools named '#{tool_name}' found across servers (#{server_names.join(', ')}). " \
431
- "Please specify a server using the 'server' parameter."
432
- end
433
-
434
- tool = matching_tools.first
435
- end
435
+ tool = resolve_tool(tool_name, server: server)
436
436
 
437
437
  # Validate parameters against tool schema
438
438
  validate_params!(tool, parameters)
@@ -479,9 +479,13 @@ module MCPClient
479
479
  # @param params [Hash] parameters for the request
480
480
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
481
481
  # @return [Object] result from the JSON-RPC response
482
- def send_rpc(method, params: {}, server: nil)
482
+ def send_rpc(method, params: {}, server: nil, timeout: nil)
483
483
  srv = select_server(server)
484
- srv.rpc_request(method, params)
484
+ # Only pass the per-request timeout when set, so transports (and test
485
+ # doubles) with the two-argument signature keep working.
486
+ return srv.rpc_request(method, params) unless timeout
487
+
488
+ srv.rpc_request(method, params, timeout: timeout)
485
489
  end
486
490
 
487
491
  # Send a raw JSON-RPC notification to a server (no response expected)
@@ -581,6 +585,12 @@ module MCPClient
581
585
  # Returns exactly what the underlying request would have returned (e.g. a
582
586
  # CallToolResult hash with 'content'/'isError'/'structuredContent'); it is
583
587
  # NOT wrapped in a Task. Blocks on the server until the task is terminal.
588
+ #
589
+ # NOTE: structured-content validation (see #validate_structured_content!)
590
+ # does not cover task-delivered results yet: a task ID alone does not
591
+ # identify which tool (and therefore which outputSchema) produced the
592
+ # result, and the client keeps no task-to-tool registry. Callers who need
593
+ # validation here can run MCPClient::SchemaValidator.validate themselves.
584
594
  # @param task_id [String] the ID of the task
585
595
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
586
596
  # @return [Object] the underlying task result
@@ -605,6 +615,8 @@ module MCPClient
605
615
  # @raise [MCPClient::Errors::TaskError] if listing fails
606
616
  def list_tasks(cursor: nil, server: nil)
607
617
  srv = select_server(server)
618
+ ensure_task_capability!(srv, 'list')
619
+
608
620
  params = cursor ? { cursor: cursor } : {}
609
621
 
610
622
  begin
@@ -625,6 +637,7 @@ module MCPClient
625
637
  # @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task)
626
638
  def cancel_task(task_id, server: nil)
627
639
  srv = select_server(server)
640
+ ensure_task_capability!(srv, 'cancel')
628
641
 
629
642
  begin
630
643
  result = srv.rpc_request('tasks/cancel', { taskId: task_id })
@@ -649,11 +662,57 @@ module MCPClient
649
662
  # @return [Array<Hash>] results from servers
650
663
  # @raise [MCPClient::Errors::ServerError] if server returns an error
651
664
  def log_level=(level)
652
- @servers.map { |srv| srv.log_level = level }
665
+ @servers.filter_map do |srv|
666
+ # MCP lifecycle: only use capabilities that were successfully
667
+ # negotiated — skip servers whose NEGOTIATED set lacks logging.
668
+ # Unconnected servers proceed: the transport-level gate re-checks
669
+ # after its handshake establishes the capability set.
670
+ unless !capabilities_known?(srv) || srv.capability?('logging')
671
+ @logger.debug("Skipping logging/setLevel for #{srv.name || srv.class.name}: " \
672
+ 'logging capability not negotiated')
673
+ next
674
+ end
675
+
676
+ srv.log_level = level
677
+ end
653
678
  end
654
679
 
655
680
  private
656
681
 
682
+ # Whether the server's negotiated capability set is available yet.
683
+ # @param srv [MCPClient::ServerBase] the server
684
+ # @return [Boolean]
685
+ def capabilities_known?(srv)
686
+ srv.respond_to?(:capabilities) && !srv.capabilities.nil?
687
+ end
688
+
689
+ # Enforce the tasks.<operation> capability gate for a server (MCP
690
+ # lifecycle: "Only use capabilities that were successfully negotiated").
691
+ # When the negotiated capability set is not yet known, first trigger the
692
+ # handshake with a cheap standard request (ping) and then re-apply the
693
+ # gate against the freshly negotiated set, so a previously uninitialized
694
+ # server that negotiates no tasks capability never receives the
695
+ # prohibited request.
696
+ # @param srv [MCPClient::ServerBase] the selected server
697
+ # @param operation [String] the tasks sub-capability ('list' or 'cancel')
698
+ # @return [void]
699
+ # @raise [MCPClient::Errors::CapabilityError] if the negotiated set lacks the capability
700
+ def ensure_task_capability!(srv, operation)
701
+ if !capabilities_known?(srv) && srv.respond_to?(:ping)
702
+ begin
703
+ srv.ping
704
+ rescue MCPClient::Errors::MCPError
705
+ # Initialization failed; fall through and let the task request
706
+ # itself surface the failure via the normal error path.
707
+ end
708
+ end
709
+
710
+ return if !capabilities_known?(srv) || srv.capability?('tasks', operation)
711
+
712
+ raise MCPClient::Errors::CapabilityError,
713
+ "Server #{srv.name || srv.class.name} did not declare the tasks.#{operation} capability"
714
+ end
715
+
657
716
  # Process incoming JSON-RPC notifications with default handlers
658
717
  # @param server [MCPClient::ServerBase] the server that emitted the notification
659
718
  # @param method [String] JSON-RPC notification method
@@ -679,6 +738,16 @@ module MCPClient
679
738
  when 'notifications/tasks/status'
680
739
  # MCP 2025-11-25: task status update (params are a flat Task)
681
740
  handle_task_status_notification(server_id, params)
741
+ when 'notifications/cancelled'
742
+ # MCP 2025-11-25 cancellation utility: the server cancelled one of its
743
+ # own in-flight requests (sampling/elicitation). Server-request
744
+ # dispatch is synchronous per transport, so by the time this arrives
745
+ # the handler has usually completed; receivers MAY ignore
746
+ # cancellations they cannot honor — log for observability.
747
+ logger.debug("[#{server_id}] Server cancelled request #{params&.dig('requestId')}: " \
748
+ "#{params&.dig('reason') || 'no reason given'}")
749
+ when 'notifications/progress'
750
+ handle_progress_notification(server_id, params)
682
751
  else
683
752
  # Log unknown notification types for debugging purposes
684
753
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
@@ -689,6 +758,66 @@ module MCPClient
689
758
  # @param server_id [String] server identifier for log prefix
690
759
  # @param params [Hash] log message params (level, logger, data)
691
760
  # @return [void]
761
+ # Route a notifications/progress message to the callback registered for
762
+ # its progressToken; unknown or stale tokens are debug-logged and dropped
763
+ # (MCP: "Senders and receivers SHOULD track active progress tokens").
764
+ # @param server_id [String] identity of the emitting server (for logs)
765
+ # @param params [Hash, nil] notification params
766
+ # @return [void]
767
+ def handle_progress_notification(server_id, params)
768
+ token = params && params['progressToken']
769
+ callback = @progress_mutex.synchronize { @progress_callbacks[token] }
770
+ unless callback
771
+ logger.debug("[#{server_id}] Progress for unknown or completed token #{token.inspect}")
772
+ return
773
+ end
774
+
775
+ callback.call(params['progress'], params['total'], params['message'])
776
+ rescue StandardError => e
777
+ logger.warn("[#{server_id}] Progress callback error: #{e.message}")
778
+ end
779
+
780
+ # Attach progress tracking to an outgoing request when requested.
781
+ # @param parameters [Hash] user arguments
782
+ # @param progress [#call, nil] optional progress callback
783
+ # @return [Array(Hash, String|nil)] possibly-augmented parameters and token
784
+ def setup_progress_tracking(parameters, progress)
785
+ return [parameters, nil] unless progress
786
+
787
+ token = generate_progress_token
788
+ register_progress_callback(token, progress)
789
+ [attach_progress_token(parameters, token), token]
790
+ end
791
+
792
+ # @return [String] a unique progress token for an outgoing request
793
+ def generate_progress_token
794
+ "rb-mcp-#{SecureRandom.hex(8)}"
795
+ end
796
+
797
+ # @param parameters [Hash] user arguments (not mutated)
798
+ # @param token [String] progress token
799
+ # @return [Hash] parameters with _meta.progressToken merged in
800
+ def attach_progress_token(parameters, token)
801
+ params = parameters.dup
802
+ meta = (params['_meta'] || params[:_meta] || {}).merge('progressToken' => token)
803
+ params.delete(:_meta)
804
+ params['_meta'] = meta
805
+ params
806
+ end
807
+
808
+ # @param token [String] progress token
809
+ # @param callback [#call] receives (progress, total, message)
810
+ # @return [void]
811
+ def register_progress_callback(token, callback)
812
+ @progress_mutex.synchronize { @progress_callbacks[token] = callback }
813
+ end
814
+
815
+ # @param token [String] progress token
816
+ # @return [void]
817
+ def unregister_progress_callback(token)
818
+ @progress_mutex.synchronize { @progress_callbacks.delete(token) }
819
+ end
820
+
692
821
  def handle_log_message(server_id, params)
693
822
  level = params['level'] || 'info'
694
823
  logger_name = params['logger']
@@ -772,6 +901,73 @@ module MCPClient
772
901
  raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
773
902
  end
774
903
 
904
+ # Validate a tools/call result's structuredContent against the tool's
905
+ # declared outputSchema (MCP 2025-11-25 server/tools spec: "Clients SHOULD
906
+ # validate structured results against this schema"; a tool declaring an
907
+ # outputSchema must return structuredContent in successful results). Error
908
+ # results (isError: true) are exempt: the conformance requirements apply to
909
+ # successful results only. Validation covers the common JSON Schema
910
+ # keywords; the full 2020-12 vocabulary is out of scope (see
911
+ # MCPClient::SchemaValidator), and when the schema uses keywords outside
912
+ # that subset a partial-coverage warning is logged in both modes so :strict
913
+ # never silently passes what it cannot fully check. On a violation
914
+ # (mismatch or missing structuredContent) a warning is always logged, and
915
+ # in :strict mode a ValidationError is raised as well.
916
+ # @param tool [MCPClient::Tool] the tool that produced the result
917
+ # @param result [Object] the raw tools/call result
918
+ # @return [Object] the result, unchanged
919
+ # @raise [MCPClient::Errors::ValidationError] in :strict mode when structuredContent
920
+ # is missing from a successful result or does not match the schema
921
+ def validate_structured_content!(tool, result)
922
+ return result unless tool.structured_output? && result.is_a?(Hash)
923
+ return result if result['isError'] || result[:isError]
924
+
925
+ warn_partial_schema_coverage(tool)
926
+
927
+ structured = result.key?('structuredContent') ? result['structuredContent'] : result[:structuredContent]
928
+ if structured.nil?
929
+ handle_structured_content_violation(
930
+ "Tool '#{tool.name}' declares an output schema but its successful result carries no structuredContent " \
931
+ '(required by the MCP 2025-11-25 tools spec)'
932
+ )
933
+ return result
934
+ end
935
+
936
+ errors = MCPClient::SchemaValidator.validate(structured, tool.output_schema)
937
+ unless errors.empty?
938
+ handle_structured_content_violation(
939
+ "Structured content for tool '#{tool.name}' does not match its output schema: #{errors.join('; ')}"
940
+ )
941
+ end
942
+ result
943
+ end
944
+
945
+ # Warn (in both :warn and :strict modes) when a tool's output schema uses
946
+ # JSON Schema keywords the built-in validator cannot evaluate, so partial
947
+ # coverage is never silent.
948
+ # @param tool [MCPClient::Tool] the tool whose output schema is being used
949
+ # @return [void]
950
+ def warn_partial_schema_coverage(tool)
951
+ unsupported = MCPClient::SchemaValidator.unsupported_keywords(tool.output_schema)
952
+ return if unsupported.empty?
953
+
954
+ @logger.warn(
955
+ "Structured content check for tool '#{tool.name}': validation is partial: schema uses unsupported " \
956
+ "keywords: #{unsupported.join(', ')} (full JSON Schema 2020-12 evaluation is not implemented, so " \
957
+ 'conforming-looking data may still violate the schema)'
958
+ )
959
+ end
960
+
961
+ # Log a structured-content conformance violation and, in :strict mode,
962
+ # raise it as a ValidationError.
963
+ # @param message [String] the violation description
964
+ # @return [void]
965
+ # @raise [MCPClient::Errors::ValidationError] in :strict mode
966
+ def handle_structured_content_violation(message)
967
+ @logger.warn(message)
968
+ raise MCPClient::Errors::ValidationError, message if @validate_structured_content == :strict
969
+ end
970
+
775
971
  def find_server_for_tool(tool)
776
972
  servers.find do |server|
777
973
  server.list_tools.any? { |t| t.name == tool.name }
@@ -817,7 +1013,10 @@ module MCPClient
817
1013
  # @param tool_name [String] the tool name (for the message)
818
1014
  # @raise [MCPClient::Errors::ToolCallError] if the tool requires task execution
819
1015
  def reject_task_required!(tool, tool_name)
820
- return unless tool.task_required?
1016
+ # Tasks Tool-Level Negotiation rule 1: without tasks.requests.tools.call
1017
+ # in the server capabilities, taskSupport is disregarded entirely and
1018
+ # the tool is invoked as a plain call.
1019
+ return unless tool.task_required? && server_supports_task_tool_call?(tool.server)
821
1020
 
822
1021
  raise MCPClient::Errors::ToolCallError,
823
1022
  "Tool '#{tool_name}' requires task-augmented execution; call it with call_tool_as_task instead"
@@ -934,13 +1133,23 @@ module MCPClient
934
1133
  # @param params [Hash] the elicitation parameters
935
1134
  # @return [Hash] the elicitation response
936
1135
  def handle_elicitation_request(_request_id, params)
937
- # If no handler is configured, decline the request
1136
+ mode = params['mode'] || 'form'
1137
+ # MCP 2025-11-25: requests with a mode not declared in client
1138
+ # capabilities MUST be rejected with -32602 (Invalid params). This check
1139
+ # precedes everything else — an undeclared mode is -32602 even when no
1140
+ # handler is configured.
1141
+ unless SUPPORTED_ELICITATION_MODES.include?(mode)
1142
+ @logger.warn("Rejecting elicitation request with unsupported mode '#{mode}'")
1143
+ return jsonrpc_error_result(-32_602, "Elicitation mode '#{mode}' is not supported")
1144
+ end
1145
+
1146
+ # Without a handler there is no user to interact with: answer with a
1147
+ # JSON-RPC error rather than fabricating a user "decline".
938
1148
  unless @elicitation_handler
939
- @logger.warn('Received elicitation request but no handler configured, declining')
940
- return { 'action' => 'decline' }
1149
+ @logger.warn('Received elicitation request but no elicitation handler is configured')
1150
+ return jsonrpc_error_result(-32_601, 'Elicitation not supported: no elicitation handler configured')
941
1151
  end
942
1152
 
943
- mode = params['mode'] || 'form'
944
1153
  message = params['message']
945
1154
 
946
1155
  begin
@@ -954,10 +1163,19 @@ module MCPClient
954
1163
  rescue StandardError => e
955
1164
  @logger.error("Elicitation handler error: #{e.message}")
956
1165
  @logger.debug(e.backtrace.join("\n"))
957
- { 'action' => 'decline' }
1166
+ jsonrpc_error_result(-32_603, "Elicitation handler error: #{e.message}")
958
1167
  end
959
1168
  end
960
1169
 
1170
+ # Build an error-shaped handler result that transports turn into a
1171
+ # JSON-RPC error response (mirroring the sampling error path).
1172
+ # @param code [Integer] JSON-RPC error code
1173
+ # @param message [String] error message
1174
+ # @return [Hash] error result
1175
+ def jsonrpc_error_result(code, message)
1176
+ { 'error' => { 'code' => code, 'message' => message } }
1177
+ end
1178
+
961
1179
  # Handle form mode elicitation (MCP 2025-11-25)
962
1180
  # @param params [Hash] the elicitation parameters
963
1181
  # @param message [String] the human-readable message
@@ -1012,47 +1230,65 @@ module MCPClient
1012
1230
  # @param params [Hash] original request params (for schema validation)
1013
1231
  # @return [Hash] formatted response
1014
1232
  def format_elicitation_response(result, params)
1015
- response = case result
1016
- when Hash
1017
- if result['action']
1018
- normalised_action_response(result)
1019
- elsif result[:action]
1020
- {
1021
- 'action' => result[:action].to_s,
1022
- 'content' => result[:content]
1023
- }.compact.then { |payload| normalised_action_response(payload) }
1024
- else
1025
- { 'action' => 'accept', 'content' => result }
1026
- end
1027
- when nil
1028
- { 'action' => 'cancel' }
1029
- else
1030
- { 'action' => 'accept', 'content' => result }
1031
- end
1233
+ response = normalize_elicitation_result(result)
1234
+
1235
+ # Per the ElicitResult schema, content is only present when the action
1236
+ # is accept and the mode was form; it is omitted for decline/cancel and
1237
+ # for out-of-band (url) mode responses.
1238
+ response.delete('content') if response['action'] != 'accept' || (params['mode'] || 'form') == 'url'
1239
+
1240
+ # ElicitResult.content is an object mapping property names to primitive
1241
+ # values a scalar cannot be transmitted.
1242
+ if response.key?('content') && !response['content'].is_a?(Hash)
1243
+ @logger.warn("Elicitation handler returned non-object content (#{response['content'].class})")
1244
+ return jsonrpc_error_result(-32_603, 'Elicitation content must be an object of primitive values')
1245
+ end
1032
1246
 
1033
- # Validate content against schema for form mode accept responses
1034
- validate_elicitation_content(response, params)
1247
+ # Validate content against schema for form mode accept responses; do not
1248
+ # transmit content that violates the requestedSchema (spec SHOULD).
1249
+ errors = validate_elicitation_content(response, params)
1250
+ unless errors.empty?
1251
+ @logger.warn("Elicitation content validation failed: #{errors.join('; ')}")
1252
+ return jsonrpc_error_result(-32_603, "Elicitation content failed schema validation: #{errors.join('; ')}")
1253
+ end
1035
1254
 
1036
1255
  response
1037
1256
  end
1038
1257
 
1258
+ # Normalize a handler's return value into a string-keyed ElicitResult
1259
+ # shape, so mixed or symbol keys cannot bypass content handling.
1260
+ # @param result [Object] handler result
1261
+ # @return [Hash] normalized response with string keys
1262
+ def normalize_elicitation_result(result)
1263
+ case result
1264
+ when Hash
1265
+ action = result['action'] || result[:action]
1266
+ return { 'action' => 'accept', 'content' => result } unless action
1267
+
1268
+ content = result.key?('content') || result.key?(:content) ? (result['content'] || result[:content]) : nil
1269
+ meta = result['_meta'] || result[:_meta]
1270
+ normalised_action_response({ 'action' => action.to_s, 'content' => content, '_meta' => meta }.compact)
1271
+ when nil
1272
+ { 'action' => 'cancel' }
1273
+ else
1274
+ { 'action' => 'accept', 'content' => result }
1275
+ end
1276
+ end
1277
+
1039
1278
  # Validate elicitation response content against the requestedSchema
1040
1279
  # @param response [Hash] the formatted response
1041
1280
  # @param params [Hash] original request params
1042
- # @return [void]
1281
+ # @return [Array<String>] validation errors (empty when conforming or not applicable)
1043
1282
  def validate_elicitation_content(response, params)
1044
- return unless response['action'] == 'accept' && response['content'].is_a?(Hash)
1283
+ return [] unless response['action'] == 'accept' && response['content'].is_a?(Hash)
1045
1284
 
1046
1285
  mode = params['mode'] || 'form'
1047
- return unless mode == 'form'
1286
+ return [] unless mode == 'form'
1048
1287
 
1049
1288
  schema = params['requestedSchema'] || params['schema']
1050
- return unless schema.is_a?(Hash)
1289
+ return [] unless schema.is_a?(Hash)
1051
1290
 
1052
- errors = ElicitationValidator.validate_content(response['content'], schema)
1053
- return if errors.empty?
1054
-
1055
- @logger.warn("Elicitation content validation warnings: #{errors.join('; ')}")
1291
+ ElicitationValidator.validate_content(response['content'], schema)
1056
1292
  end
1057
1293
 
1058
1294
  # Ensure the action value conforms to MCP spec (accept, decline, cancel)
@@ -1095,10 +1331,18 @@ module MCPClient
1095
1331
  # @return [void]
1096
1332
  def notify_roots_changed
1097
1333
  @servers.each do |server|
1098
- server.rpc_notify('notifications/roots/list_changed', {})
1099
- rescue StandardError => e
1100
- server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
1101
- @logger.warn("[#{server_id}] Failed to send roots/list_changed notification: #{e.message}")
1334
+ # Only notify sessions where the roots capability could be declared:
1335
+ # MCP forbids using capabilities that were not negotiated, and
1336
+ # transports without a server-request channel (plain HTTP) never
1337
+ # declare roots.
1338
+ next unless server.respond_to?(:on_roots_list_request)
1339
+
1340
+ begin
1341
+ server.rpc_notify('notifications/roots/list_changed', {})
1342
+ rescue StandardError => e
1343
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
1344
+ @logger.warn("[#{server_id}] Failed to send roots/list_changed notification: #{e.message}")
1345
+ end
1102
1346
  end
1103
1347
  end
1104
1348
 
@@ -1107,32 +1351,44 @@ module MCPClient
1107
1351
  # @param params [Hash] the sampling parameters
1108
1352
  # @return [Hash] the sampling response (role, content, model, stopReason)
1109
1353
  def handle_sampling_request(_request_id, params)
1110
- # If no handler is configured, return an error
1354
+ # Without a handler the sampling capability was never declared, so the
1355
+ # request targets an unsupported method: answer -32601 (Method not
1356
+ # found) rather than -1, which sampling.mdx § Error Handling reserves
1357
+ # for "User rejected sampling request".
1111
1358
  unless @sampling_handler
1112
- @logger.warn('Received sampling request but no handler configured')
1113
- return { 'error' => { 'code' => -1, 'message' => 'Sampling not supported' } }
1359
+ @logger.warn('Received sampling request but no sampling handler is configured')
1360
+ return jsonrpc_error_result(-32_601, 'Sampling not supported: no sampling handler configured')
1361
+ end
1362
+
1363
+ # SEP-1577 (schema.ts CreateMessageRequestParams.tools/.toolChoice):
1364
+ # "The client MUST return an error if this field is provided but
1365
+ # ClientCapabilities.sampling.tools is not declared." -32602 is the
1366
+ # Invalid params code used by sampling.mdx § Error Handling.
1367
+ if (params.key?('tools') || params.key?('toolChoice')) && !@sampling_supports_tools
1368
+ @logger.warn('Rejecting tool-enabled sampling request: sampling.tools capability not declared')
1369
+ return jsonrpc_error_result(-32_602,
1370
+ 'Invalid params: tools/toolChoice provided but the sampling.tools ' \
1371
+ 'capability was not declared')
1114
1372
  end
1115
1373
 
1116
1374
  messages = params['messages'] || []
1117
1375
  model_preferences = normalize_model_preferences(params['modelPreferences'])
1118
1376
  system_prompt = params['systemPrompt']
1119
1377
  max_tokens = params['maxTokens']
1120
- include_context = params['includeContext']
1121
- temperature = params['temperature']
1122
- stop_sequences = params['stopSequences']
1123
- metadata = params['metadata']
1124
1378
 
1125
1379
  begin
1126
1380
  # Call the user-defined handler with parameters based on arity
1127
- result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
1128
- include_context, temperature, stop_sequences, metadata)
1381
+ result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
1129
1382
 
1130
1383
  # Validate and format response
1131
1384
  validate_sampling_response(result)
1132
1385
  rescue StandardError => e
1133
1386
  @logger.error("Sampling handler error: #{e.message}")
1134
1387
  @logger.debug(e.backtrace.join("\n"))
1135
- { 'error' => { 'code' => -1, 'message' => "Sampling error: #{e.message}" } }
1388
+ # A handler exception is an internal client failure (-32603), not a
1389
+ # user rejection: sampling.mdx § Error Handling reserves -1 for
1390
+ # "User rejected sampling request".
1391
+ jsonrpc_error_result(-32_603, "Sampling error: #{e.message}")
1136
1392
  end
1137
1393
  end
1138
1394
 
@@ -1141,32 +1397,38 @@ module MCPClient
1141
1397
  # @param model_preferences [Hash, nil] normalized model preferences
1142
1398
  # @param system_prompt [String, nil] system prompt
1143
1399
  # @param max_tokens [Integer, nil] max tokens
1144
- # @param include_context [String, nil] context inclusion setting
1145
- # @param temperature [Float, nil] temperature
1146
- # @param stop_sequences [Array, nil] stop sequences
1147
- # @param metadata [Hash, nil] metadata
1400
+ # @param params [Hash] the complete sampling/createMessage request params;
1401
+ # handlers whose fifth parameter is required, optional, or part of a
1402
+ # rest argument receive this hash verbatim, so they can read
1403
+ # includeContext, temperature, stopSequences, metadata, the SEP-1577
1404
+ # tools/toolChoice fields, _meta, and any future params
1148
1405
  # @return [Hash] the handler result
1149
- def call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
1150
- include_context, temperature, stop_sequences, metadata)
1151
- arity = @sampling_handler.arity
1152
- # Normalize negative arity (optional params) to minimum required args
1153
- arity = -(arity + 1) if arity.negative?
1154
- case arity
1155
- when 0
1156
- @sampling_handler.call
1157
- when 1
1158
- @sampling_handler.call(messages)
1159
- when 2
1160
- @sampling_handler.call(messages, model_preferences)
1161
- when 3
1162
- @sampling_handler.call(messages, model_preferences, system_prompt)
1163
- when 4
1164
- @sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
1165
- else
1166
- @sampling_handler.call(messages, model_preferences, system_prompt, max_tokens,
1167
- { 'includeContext' => include_context, 'temperature' => temperature,
1168
- 'stopSequences' => stop_sequences, 'metadata' => metadata })
1406
+ def call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
1407
+ args = [messages, model_preferences, system_prompt, max_tokens, params]
1408
+ @sampling_handler.call(*args.first(sampling_handler_arg_count))
1409
+ end
1410
+
1411
+ # Number of the five positional sampling arguments the handler can accept.
1412
+ # Arity alone cannot size variable-arity handlers: lambdas with optional
1413
+ # or rest parameters report a negative arity, and non-lambda procs with
1414
+ # optional parameters report their mandatory minimum as a nonnegative
1415
+ # arity (proc { |m, p = nil, s = nil, t = nil, extra = nil| }.arity == 1).
1416
+ # Normalizing either to the minimum required count would starve the
1417
+ # handler of the raw params (including the SEP-1577 tools/toolChoice
1418
+ # fields), so any handler whose parameters include :opt or :rest entries
1419
+ # (or whose arity is negative) is sized from Proc#parameters instead:
1420
+ # each :req/:opt parameter accepts one argument and a :rest accepts the
1421
+ # full list. Plain fixed-arity handlers keep arity-based sizing.
1422
+ # @return [Integer] how many arguments to pass, capped at 5
1423
+ def sampling_handler_arg_count
1424
+ parameters = @sampling_handler.parameters
1425
+ return 5 if parameters.any? { |type, _name| type == :rest }
1426
+
1427
+ if @sampling_handler.arity.negative? || parameters.any? { |type, _name| type == :opt }
1428
+ return [parameters.count { |type, _name| %i[req opt].include?(type) }, 5].min
1169
1429
  end
1430
+
1431
+ [@sampling_handler.arity, 5].min
1170
1432
  end
1171
1433
 
1172
1434
  # Normalize and validate modelPreferences from sampling request (MCP 2025-11-25)
@@ -1203,7 +1465,11 @@ module MCPClient
1203
1465
  # @param result [Hash] the result from the sampling handler
1204
1466
  # @return [Hash] validated sampling response
1205
1467
  def validate_sampling_response(result)
1206
- return { 'error' => { 'code' => -1, 'message' => 'Sampling rejected' } } if result.nil?
1468
+ # A nil handler result is the host's rejection signal; -1 is the code
1469
+ # sampling.mdx § Error Handling assigns to "User rejected sampling
1470
+ # request" (internal failures use -32603 instead, see
1471
+ # handle_sampling_request).
1472
+ return jsonrpc_error_result(-1, 'Sampling rejected') if result.nil?
1207
1473
 
1208
1474
  # Convert symbol keys to string keys
1209
1475
  result = result.transform_keys(&:to_s) if result.is_a?(Hash) && result.keys.first.is_a?(Symbol)
@@ -1218,15 +1484,27 @@ module MCPClient
1218
1484
  }
1219
1485
  end
1220
1486
 
1221
- # Set defaults for missing fields
1487
+ # Set defaults for missing fields. ToolUseContent blocks (SEP-1577) are
1488
+ # passed through verbatim; when the handler omits stopReason for them,
1489
+ # default to "toolUse" per the CreateMessageResult stopReason values.
1222
1490
  result['role'] ||= 'assistant'
1223
1491
  result['model'] ||= 'unknown'
1224
- result['stopReason'] ||= 'endTurn'
1492
+ result['stopReason'] ||= tool_use_content?(result['content']) ? 'toolUse' : 'endTurn'
1225
1493
 
1226
1494
  # Normalize content if it's a string
1227
1495
  result['content'] = { 'type' => 'text', 'text' => result['content'] } if result['content'].is_a?(String)
1228
1496
 
1229
1497
  result
1230
1498
  end
1499
+
1500
+ # Whether sampling response content contains ToolUseContent blocks (MCP 2025-11-25 / SEP-1577)
1501
+ # @param content [Object] the content field of a CreateMessageResult
1502
+ # @return [Boolean] true when any content block has type "tool_use"
1503
+ def tool_use_content?(content)
1504
+ blocks = content.is_a?(Array) ? content : [content]
1505
+ blocks.any? do |block|
1506
+ block.is_a?(Hash) && (block['type'] == 'tool_use' || block[:type] == 'tool_use')
1507
+ end
1508
+ end
1231
1509
  end
1232
1510
  end