ruby-mcp-client 1.1.0 → 2.1.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +216 -10
  3. data/lib/mcp_client/auth/oauth_provider.rb +325 -27
  4. data/lib/mcp_client/auth.rb +38 -11
  5. data/lib/mcp_client/client.rb +523 -150
  6. data/lib/mcp_client/elicitation_validator.rb +99 -13
  7. data/lib/mcp_client/errors.rb +43 -1
  8. data/lib/mcp_client/http_transport_base.rb +254 -41
  9. data/lib/mcp_client/json_rpc_common.rb +196 -14
  10. data/lib/mcp_client/oauth_client.rb +8 -3
  11. data/lib/mcp_client/prompt.rb +17 -2
  12. data/lib/mcp_client/resource.rb +13 -2
  13. data/lib/mcp_client/resource_content.rb +8 -3
  14. data/lib/mcp_client/resource_link.rb +14 -3
  15. data/lib/mcp_client/resource_template.rb +13 -2
  16. data/lib/mcp_client/root.rb +61 -7
  17. data/lib/mcp_client/schema_validator.rb +329 -0
  18. data/lib/mcp_client/server_base.rb +66 -0
  19. data/lib/mcp_client/server_factory.rb +4 -1
  20. data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
  21. data/lib/mcp_client/server_http.rb +18 -12
  22. data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
  23. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  24. data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
  25. data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
  26. data/lib/mcp_client/server_sse.rb +132 -35
  27. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
  28. data/lib/mcp_client/server_stdio.rb +98 -20
  29. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
  30. data/lib/mcp_client/server_streamable_http.rb +472 -108
  31. data/lib/mcp_client/tool.rb +16 -3
  32. data/lib/mcp_client/version.rb +6 -1
  33. data/lib/mcp_client.rb +9 -1
  34. metadata +5 -6
@@ -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,47 @@ 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
+
32
+ # Server-config keys whose values carry credentials (HTTP headers, the
33
+ # subprocess environment, inline tokens). Their values are replaced before
34
+ # a config is written to the log.
35
+ SENSITIVE_CONFIG_KEYS = %i[headers env token access_token api_key auth authorization
36
+ password secret client_secret oauth_provider].freeze
37
+
38
+ # Placeholder written in place of a redacted value.
39
+ REDACTED = '[REDACTED]'
40
+
41
+ # Maximum characters of a peer-supplied log message written to the host
42
+ # log. The remote server controls this content, so an unbounded message
43
+ # would let it inflate log storage at will.
44
+ MAX_PEER_LOG_MESSAGE_LENGTH = 4096
45
+
23
46
  # Initialize a new MCPClient::Client
24
47
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
25
48
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
26
49
  # @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
27
50
  # @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
28
51
  # @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)
52
+ # @param sampling_supports_tools [Boolean] whether the sampling handler supports tool use
53
+ # (MCP 2025-11-25 / SEP-1577); declares the sampling.tools capability and forwards
54
+ # tools/toolChoice params to the handler instead of rejecting tool-enabled requests
55
+ # @param client_info [Hash, nil] host-provided Implementation info sent as clientInfo
56
+ # (name and version required; title, description, websiteUrl, icons optional)
57
+ # @param validate_structured_content [Symbol] how to treat a tools/call result whose
58
+ # structuredContent does not match the tool's declared outputSchema (MCP 2025-11-25:
59
+ # "Clients SHOULD validate structured results against this schema"): :warn (default)
60
+ # logs a warning, :strict raises MCPClient::Errors::ValidationError
61
+ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil,
62
+ sampling_supports_tools: false, client_info: nil, validate_structured_content: :warn)
63
+ unless STRUCTURED_CONTENT_MODES.include?(validate_structured_content)
64
+ raise ArgumentError, "validate_structured_content must be one of #{STRUCTURED_CONTENT_MODES.inspect}, " \
65
+ "got #{validate_structured_content.inspect}"
66
+ end
67
+
68
+ @validate_structured_content = validate_structured_content
30
69
  # Preserve a caller-supplied logger's formatter (only tag progname), and
31
70
  # install the default formatter solely on a logger we create ourselves.
32
71
  # Overwriting the formatter of an application's logger would silently
@@ -40,10 +79,13 @@ module MCPClient
40
79
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
41
80
  end
42
81
  @servers = mcp_server_configs.map do |config|
43
- @logger.debug("Creating server with config: #{config.inspect}")
82
+ @logger.debug("Creating server with config: #{redact_config(config).inspect}")
44
83
  MCPClient::ServerFactory.create(config, logger: @logger)
45
84
  end
46
85
  @tool_cache = {}
86
+ # Active progressToken -> callback registrations (MCP progress utility)
87
+ @progress_callbacks = {}
88
+ @progress_mutex = Mutex.new
47
89
  @prompt_cache = {}
48
90
  @resource_cache = {}
49
91
  # JSON-RPC notification listeners
@@ -52,24 +94,37 @@ module MCPClient
52
94
  @elicitation_handler = elicitation_handler
53
95
  # Sampling handler (MCP 2025-11-25)
54
96
  @sampling_handler = sampling_handler
97
+ # Whether the sampling handler supports tool use (SEP-1577)
98
+ @sampling_supports_tools = sampling_supports_tools
55
99
  # Roots (MCP 2025-06-18)
56
100
  @roots = normalize_roots(roots)
57
101
  # Register default and user-defined notification handlers on each server
58
102
  @servers.each do |server|
103
+ # Host-provided Implementation info for the initialize clientInfo
104
+ server.client_info = client_info if client_info && server.respond_to?(:client_info=)
59
105
  server.on_notification do |method, params|
60
106
  # Default notification processing (e.g., cache invalidation, logging)
61
107
  process_notification(server, method, params)
62
108
  # Invoke user-defined listeners
63
109
  @notification_listeners.each { |cb| cb.call(server, method, params) }
64
110
  end
65
- # Register elicitation handler on each server
66
- if server.respond_to?(:on_elicitation_request)
111
+ # Register feature callbacks only for features the host actually
112
+ # supports: transports derive their declared client capabilities from
113
+ # the callbacks registered before connecting, and MCP forbids using
114
+ # capabilities that were not negotiated.
115
+ if @elicitation_handler && server.respond_to?(:on_elicitation_request)
67
116
  server.on_elicitation_request(&method(:handle_elicitation_request))
68
117
  end
69
- # Register roots list handler on each server (MCP 2025-06-18)
118
+ # The client always implements the roots feature (roots/list and
119
+ # list_changed notifications), independent of the current roots list.
70
120
  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)
121
+ next unless @sampling_handler && server.respond_to?(:on_sampling_request)
122
+
123
+ server.on_sampling_request(&method(:handle_sampling_request))
124
+ # Declare the sampling.tools sub-capability (SEP-1577) only when the
125
+ # host opted in; the transport derives its initialize declaration
126
+ # from this before connecting.
127
+ server.declare_sampling_tools if @sampling_supports_tools && server.respond_to?(:declare_sampling_tools)
73
128
  end
74
129
  end
75
130
 
@@ -259,34 +314,8 @@ module MCPClient
259
314
  # @param parameters [Hash] the parameters to pass to the tool
260
315
  # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
261
316
  # @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
317
+ def call_tool(tool_name, parameters, server: nil, progress: nil)
318
+ tool = resolve_tool(tool_name, server: server)
290
319
 
291
320
  # Validate parameters against tool schema
292
321
  validate_params!(tool, parameters)
@@ -296,13 +325,24 @@ module MCPClient
296
325
  server = tool.server
297
326
  raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
298
327
 
299
- begin
328
+ # MCP progress utility: attach an auto-generated progressToken to the
329
+ # request _meta and route matching notifications/progress to the
330
+ # caller's callback while the request is active.
331
+ parameters, token = setup_progress_tracking(parameters, progress)
332
+
333
+ result = begin
300
334
  server.call_tool(tool_name, parameters)
301
335
  rescue MCPClient::Errors::ConnectionError => e
302
336
  # Add server identity information to the error for better context
303
337
  server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
304
338
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message} (Server: #{server_id})"
339
+ ensure
340
+ # Tokens are only valid for the lifetime of the request: dropping the
341
+ # registration filters out stale post-completion notifications.
342
+ unregister_progress_callback(token) if token
305
343
  end
344
+
345
+ validate_structured_content!(tool, result)
306
346
  end
307
347
 
308
348
  # Convert MCP tools to OpenAI function specifications
@@ -406,33 +446,7 @@ module MCPClient
406
446
  # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
407
447
  # @return [Enumerator] streaming enumerator or single-value enumerator
408
448
  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
449
+ tool = resolve_tool(tool_name, server: server)
436
450
 
437
451
  # Validate parameters against tool schema
438
452
  validate_params!(tool, parameters)
@@ -479,9 +493,13 @@ module MCPClient
479
493
  # @param params [Hash] parameters for the request
480
494
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
481
495
  # @return [Object] result from the JSON-RPC response
482
- def send_rpc(method, params: {}, server: nil)
496
+ def send_rpc(method, params: {}, server: nil, timeout: nil)
483
497
  srv = select_server(server)
484
- srv.rpc_request(method, params)
498
+ # Only pass the per-request timeout when set, so transports (and test
499
+ # doubles) with the two-argument signature keep working.
500
+ return srv.rpc_request(method, params) unless timeout
501
+
502
+ srv.rpc_request(method, params, timeout: timeout)
485
503
  end
486
504
 
487
505
  # Send a raw JSON-RPC notification to a server (no response expected)
@@ -558,14 +576,17 @@ module MCPClient
558
576
  end
559
577
 
560
578
  # Get the current state of a task (tasks/get, MCP 2025-11-25)
561
- # @param task_id [String] the ID of the task to query
579
+ # @param task_id [String, MCPClient::Task] the task to query; passing the
580
+ # Task handle returned by #call_tool_as_task routes to its own server
562
581
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
563
582
  # @return [MCPClient::Task] the task with current status
583
+ # @raise [ArgumentError] if the server is ambiguous in a multi-server client
564
584
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
565
585
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
566
586
  # @raise [MCPClient::Errors::TaskError] if retrieving the task fails
567
587
  def get_task(task_id, server: nil)
568
- srv = select_server(server)
588
+ srv = select_task_server(task_id, server, 'get_task')
589
+ task_id = task_identifier(task_id)
569
590
 
570
591
  begin
571
592
  result = srv.rpc_request('tasks/get', { taskId: task_id })
@@ -581,13 +602,22 @@ module MCPClient
581
602
  # Returns exactly what the underlying request would have returned (e.g. a
582
603
  # CallToolResult hash with 'content'/'isError'/'structuredContent'); it is
583
604
  # NOT wrapped in a Task. Blocks on the server until the task is terminal.
584
- # @param task_id [String] the ID of the task
605
+ #
606
+ # NOTE: structured-content validation (see #validate_structured_content!)
607
+ # does not cover task-delivered results yet: a task ID alone does not
608
+ # identify which tool (and therefore which outputSchema) produced the
609
+ # result, and the client keeps no task-to-tool registry. Callers who need
610
+ # validation here can run MCPClient::SchemaValidator.validate themselves.
611
+ # @param task_id [String, MCPClient::Task] the task; passing the Task
612
+ # handle returned by #call_tool_as_task routes to its own server
585
613
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
586
614
  # @return [Object] the underlying task result
615
+ # @raise [ArgumentError] if the server is ambiguous in a multi-server client
587
616
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
588
617
  # @raise [MCPClient::Errors::TaskError] if retrieval fails
589
618
  def get_task_result(task_id, server: nil)
590
- srv = select_server(server)
619
+ srv = select_task_server(task_id, server, 'get_task_result')
620
+ task_id = task_identifier(task_id)
591
621
 
592
622
  begin
593
623
  srv.rpc_request('tasks/result', { taskId: task_id })
@@ -605,6 +635,8 @@ module MCPClient
605
635
  # @raise [MCPClient::Errors::TaskError] if listing fails
606
636
  def list_tasks(cursor: nil, server: nil)
607
637
  srv = select_server(server)
638
+ ensure_task_capability!(srv, 'list')
639
+
608
640
  params = cursor ? { cursor: cursor } : {}
609
641
 
610
642
  begin
@@ -617,14 +649,18 @@ module MCPClient
617
649
  end
618
650
 
619
651
  # Cancel a task (tasks/cancel, MCP 2025-11-25)
620
- # @param task_id [String] the ID of the task to cancel
652
+ # @param task_id [String, MCPClient::Task] the task to cancel; passing the
653
+ # Task handle returned by #call_tool_as_task routes to its own server
621
654
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
622
655
  # @return [MCPClient::Task] the task with updated (cancelled) status
656
+ # @raise [ArgumentError] if the server is ambiguous in a multi-server client
623
657
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
624
658
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
625
659
  # @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task)
626
660
  def cancel_task(task_id, server: nil)
627
- srv = select_server(server)
661
+ srv = select_task_server(task_id, server, 'cancel_task')
662
+ task_id = task_identifier(task_id)
663
+ ensure_task_capability!(srv, 'cancel')
628
664
 
629
665
  begin
630
666
  result = srv.rpc_request('tasks/cancel', { taskId: task_id })
@@ -649,11 +685,57 @@ module MCPClient
649
685
  # @return [Array<Hash>] results from servers
650
686
  # @raise [MCPClient::Errors::ServerError] if server returns an error
651
687
  def log_level=(level)
652
- @servers.map { |srv| srv.log_level = level }
688
+ @servers.filter_map do |srv|
689
+ # MCP lifecycle: only use capabilities that were successfully
690
+ # negotiated — skip servers whose NEGOTIATED set lacks logging.
691
+ # Unconnected servers proceed: the transport-level gate re-checks
692
+ # after its handshake establishes the capability set.
693
+ unless !capabilities_known?(srv) || srv.capability?('logging')
694
+ @logger.debug("Skipping logging/setLevel for #{srv.name || srv.class.name}: " \
695
+ 'logging capability not negotiated')
696
+ next
697
+ end
698
+
699
+ srv.log_level = level
700
+ end
653
701
  end
654
702
 
655
703
  private
656
704
 
705
+ # Whether the server's negotiated capability set is available yet.
706
+ # @param srv [MCPClient::ServerBase] the server
707
+ # @return [Boolean]
708
+ def capabilities_known?(srv)
709
+ srv.respond_to?(:capabilities) && !srv.capabilities.nil?
710
+ end
711
+
712
+ # Enforce the tasks.<operation> capability gate for a server (MCP
713
+ # lifecycle: "Only use capabilities that were successfully negotiated").
714
+ # When the negotiated capability set is not yet known, first trigger the
715
+ # handshake with a cheap standard request (ping) and then re-apply the
716
+ # gate against the freshly negotiated set, so a previously uninitialized
717
+ # server that negotiates no tasks capability never receives the
718
+ # prohibited request.
719
+ # @param srv [MCPClient::ServerBase] the selected server
720
+ # @param operation [String] the tasks sub-capability ('list' or 'cancel')
721
+ # @return [void]
722
+ # @raise [MCPClient::Errors::CapabilityError] if the negotiated set lacks the capability
723
+ def ensure_task_capability!(srv, operation)
724
+ if !capabilities_known?(srv) && srv.respond_to?(:ping)
725
+ begin
726
+ srv.ping
727
+ rescue MCPClient::Errors::MCPError
728
+ # Initialization failed; fall through and let the task request
729
+ # itself surface the failure via the normal error path.
730
+ end
731
+ end
732
+
733
+ return if !capabilities_known?(srv) || srv.capability?('tasks', operation)
734
+
735
+ raise MCPClient::Errors::CapabilityError,
736
+ "Server #{srv.name || srv.class.name} did not declare the tasks.#{operation} capability"
737
+ end
738
+
657
739
  # Process incoming JSON-RPC notifications with default handlers
658
740
  # @param server [MCPClient::ServerBase] the server that emitted the notification
659
741
  # @param method [String] JSON-RPC notification method
@@ -679,6 +761,16 @@ module MCPClient
679
761
  when 'notifications/tasks/status'
680
762
  # MCP 2025-11-25: task status update (params are a flat Task)
681
763
  handle_task_status_notification(server_id, params)
764
+ when 'notifications/cancelled'
765
+ # MCP 2025-11-25 cancellation utility: the server cancelled one of its
766
+ # own in-flight requests (sampling/elicitation). Server-request
767
+ # dispatch is synchronous per transport, so by the time this arrives
768
+ # the handler has usually completed; receivers MAY ignore
769
+ # cancellations they cannot honor — log for observability.
770
+ logger.debug("[#{server_id}] Server cancelled request #{params&.dig('requestId')}: " \
771
+ "#{params&.dig('reason') || 'no reason given'}")
772
+ when 'notifications/progress'
773
+ handle_progress_notification(server_id, params)
682
774
  else
683
775
  # Log unknown notification types for debugging purposes
684
776
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
@@ -689,14 +781,75 @@ module MCPClient
689
781
  # @param server_id [String] server identifier for log prefix
690
782
  # @param params [Hash] log message params (level, logger, data)
691
783
  # @return [void]
784
+ # Route a notifications/progress message to the callback registered for
785
+ # its progressToken; unknown or stale tokens are debug-logged and dropped
786
+ # (MCP: "Senders and receivers SHOULD track active progress tokens").
787
+ # @param server_id [String] identity of the emitting server (for logs)
788
+ # @param params [Hash, nil] notification params
789
+ # @return [void]
790
+ def handle_progress_notification(server_id, params)
791
+ token = params && params['progressToken']
792
+ callback = @progress_mutex.synchronize { @progress_callbacks[token] }
793
+ unless callback
794
+ logger.debug("[#{server_id}] Progress for unknown or completed token #{token.inspect}")
795
+ return
796
+ end
797
+
798
+ callback.call(params['progress'], params['total'], params['message'])
799
+ rescue StandardError => e
800
+ logger.warn("[#{server_id}] Progress callback error: #{e.message}")
801
+ end
802
+
803
+ # Attach progress tracking to an outgoing request when requested.
804
+ # @param parameters [Hash] user arguments
805
+ # @param progress [#call, nil] optional progress callback
806
+ # @return [Array(Hash, String|nil)] possibly-augmented parameters and token
807
+ def setup_progress_tracking(parameters, progress)
808
+ return [parameters, nil] unless progress
809
+
810
+ token = generate_progress_token
811
+ register_progress_callback(token, progress)
812
+ [attach_progress_token(parameters, token), token]
813
+ end
814
+
815
+ # @return [String] a unique progress token for an outgoing request
816
+ def generate_progress_token
817
+ "rb-mcp-#{SecureRandom.hex(8)}"
818
+ end
819
+
820
+ # @param parameters [Hash] user arguments (not mutated)
821
+ # @param token [String] progress token
822
+ # @return [Hash] parameters with _meta.progressToken merged in
823
+ def attach_progress_token(parameters, token)
824
+ params = parameters.dup
825
+ meta = (params['_meta'] || params[:_meta] || {}).merge('progressToken' => token)
826
+ params.delete(:_meta)
827
+ params['_meta'] = meta
828
+ params
829
+ end
830
+
831
+ # @param token [String] progress token
832
+ # @param callback [#call] receives (progress, total, message)
833
+ # @return [void]
834
+ def register_progress_callback(token, callback)
835
+ @progress_mutex.synchronize { @progress_callbacks[token] = callback }
836
+ end
837
+
838
+ # @param token [String] progress token
839
+ # @return [void]
840
+ def unregister_progress_callback(token)
841
+ @progress_mutex.synchronize { @progress_callbacks.delete(token) }
842
+ end
843
+
692
844
  def handle_log_message(server_id, params)
693
845
  level = params['level'] || 'info'
694
846
  logger_name = params['logger']
695
847
  data = params['data']
696
848
 
697
- # Format the message
698
- prefix = logger_name ? "[#{server_id}:#{logger_name}]" : "[#{server_id}]"
699
- message = data.is_a?(String) ? data : data.inspect
849
+ # Format the message. Both the logger name and the payload come from the
850
+ # remote server, so both are sanitized before they reach the host log.
851
+ prefix = logger_name ? "[#{server_id}:#{sanitize_peer_log_text(logger_name.to_s)}]" : "[#{server_id}]"
852
+ message = sanitize_peer_log_text(data.is_a?(String) ? data : data.inspect)
700
853
 
701
854
  # Map MCP log levels to Ruby Logger levels
702
855
  case level.to_s.downcase
@@ -709,10 +862,75 @@ module MCPClient
709
862
  when 'error', 'critical', 'alert', 'emergency'
710
863
  logger.error("#{prefix} #{message}")
711
864
  else
712
- logger.info("#{prefix} [#{level}] #{message}")
865
+ # An out-of-enum level is peer-controlled text like any other: it must
866
+ # be sanitized and capped, or it becomes the log-forging vector the
867
+ # sanitizing of `data` was added to close.
868
+ logger.info("#{prefix} [#{sanitize_peer_log_text(level.to_s)}] #{message}")
869
+ end
870
+ end
871
+
872
+ # Make peer-supplied log text safe to write to the host log: control
873
+ # characters (notably newlines, which would let a server forge additional
874
+ # log entries) are escaped, and the result is capped.
875
+ # @param text [String] the peer-supplied text
876
+ # @return [String] sanitized, length-bounded text
877
+ def sanitize_peer_log_text(text)
878
+ escaped = text.gsub(/[-]/) { |c| format('\\x%02X', c.ord) }
879
+ return escaped if escaped.length <= MAX_PEER_LOG_MESSAGE_LENGTH
880
+
881
+ "#{escaped[0, MAX_PEER_LOG_MESSAGE_LENGTH]}... (truncated from #{escaped.length} chars)"
882
+ end
883
+
884
+ # Copy of a server config with credential-bearing values replaced, for
885
+ # safe logging. Nested hashes (headers, env) have every value redacted;
886
+ # sensitive scalars are replaced outright.
887
+ # @param config [Hash, Object] a server configuration
888
+ # @return [Hash, Object] a redacted copy (non-Hash input is returned as-is)
889
+ def redact_config(config)
890
+ return config unless config.is_a?(Hash)
891
+
892
+ config.to_h do |key, value|
893
+ next [key, value] unless SENSITIVE_CONFIG_KEYS.include?(key.to_s.downcase.to_sym)
894
+
895
+ redacted = value.is_a?(Hash) ? value.transform_values { REDACTED } : REDACTED
896
+ [key, redacted]
713
897
  end
714
898
  end
715
899
 
900
+ # Resolve which server a task operation targets.
901
+ #
902
+ # Task IDs are only unique within the server that issued them, so silently
903
+ # defaulting to the first configured server can poll, read or cancel an
904
+ # unrelated task on the wrong server. Resolution order:
905
+ # 1. an explicit server: argument wins;
906
+ # 2. a Task handle carries the server that issued it;
907
+ # 3. a bare ID with exactly one configured server is unambiguous;
908
+ # 4. anything else is ambiguous and fails closed.
909
+ # @param task [String, MCPClient::Task] the task or its ID
910
+ # @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] explicit selector
911
+ # @param operation [String] calling method name, for the error message
912
+ # @return [MCPClient::ServerBase]
913
+ # @raise [ArgumentError] when the target server cannot be determined
914
+ def select_task_server(task, server_arg, operation)
915
+ # nil, not falsiness: `server: false` is an invalid selector that
916
+ # select_server rejects with ArgumentError, and treating it as "omitted"
917
+ # would silently route a read or a cancel somewhere instead of failing.
918
+ return select_server(server_arg) unless server_arg.nil?
919
+ return task.server if task.is_a?(MCPClient::Task) && task.server
920
+ return select_server(nil) if @servers.size <= 1
921
+
922
+ raise ArgumentError,
923
+ "#{operation} is ambiguous with multiple servers configured: task IDs are only unique per server. " \
924
+ 'Pass the Task returned by call_tool_as_task, or name the server explicitly ' \
925
+ "(e.g. #{operation}(id, server: 'name'))."
926
+ end
927
+
928
+ # @param task [String, MCPClient::Task] a task or its ID
929
+ # @return [String] the task ID
930
+ def task_identifier(task)
931
+ task.is_a?(MCPClient::Task) ? task.task_id : task
932
+ end
933
+
716
934
  # Select a server based on index, name, type, or instance
717
935
  # @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
718
936
  # @return [MCPClient::ServerBase]
@@ -772,6 +990,73 @@ module MCPClient
772
990
  raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
773
991
  end
774
992
 
993
+ # Validate a tools/call result's structuredContent against the tool's
994
+ # declared outputSchema (MCP 2025-11-25 server/tools spec: "Clients SHOULD
995
+ # validate structured results against this schema"; a tool declaring an
996
+ # outputSchema must return structuredContent in successful results). Error
997
+ # results (isError: true) are exempt: the conformance requirements apply to
998
+ # successful results only. Validation covers the common JSON Schema
999
+ # keywords; the full 2020-12 vocabulary is out of scope (see
1000
+ # MCPClient::SchemaValidator), and when the schema uses keywords outside
1001
+ # that subset a partial-coverage warning is logged in both modes so :strict
1002
+ # never silently passes what it cannot fully check. On a violation
1003
+ # (mismatch or missing structuredContent) a warning is always logged, and
1004
+ # in :strict mode a ValidationError is raised as well.
1005
+ # @param tool [MCPClient::Tool] the tool that produced the result
1006
+ # @param result [Object] the raw tools/call result
1007
+ # @return [Object] the result, unchanged
1008
+ # @raise [MCPClient::Errors::ValidationError] in :strict mode when structuredContent
1009
+ # is missing from a successful result or does not match the schema
1010
+ def validate_structured_content!(tool, result)
1011
+ return result unless tool.structured_output? && result.is_a?(Hash)
1012
+ return result if result['isError'] || result[:isError]
1013
+
1014
+ warn_partial_schema_coverage(tool)
1015
+
1016
+ structured = result.key?('structuredContent') ? result['structuredContent'] : result[:structuredContent]
1017
+ if structured.nil?
1018
+ handle_structured_content_violation(
1019
+ "Tool '#{tool.name}' declares an output schema but its successful result carries no structuredContent " \
1020
+ '(required by the MCP 2025-11-25 tools spec)'
1021
+ )
1022
+ return result
1023
+ end
1024
+
1025
+ errors = MCPClient::SchemaValidator.validate(structured, tool.output_schema)
1026
+ unless errors.empty?
1027
+ handle_structured_content_violation(
1028
+ "Structured content for tool '#{tool.name}' does not match its output schema: #{errors.join('; ')}"
1029
+ )
1030
+ end
1031
+ result
1032
+ end
1033
+
1034
+ # Warn (in both :warn and :strict modes) when a tool's output schema uses
1035
+ # JSON Schema keywords the built-in validator cannot evaluate, so partial
1036
+ # coverage is never silent.
1037
+ # @param tool [MCPClient::Tool] the tool whose output schema is being used
1038
+ # @return [void]
1039
+ def warn_partial_schema_coverage(tool)
1040
+ unsupported = MCPClient::SchemaValidator.unsupported_keywords(tool.output_schema)
1041
+ return if unsupported.empty?
1042
+
1043
+ @logger.warn(
1044
+ "Structured content check for tool '#{tool.name}': validation is partial: schema uses unsupported " \
1045
+ "keywords: #{unsupported.join(', ')} (full JSON Schema 2020-12 evaluation is not implemented, so " \
1046
+ 'conforming-looking data may still violate the schema)'
1047
+ )
1048
+ end
1049
+
1050
+ # Log a structured-content conformance violation and, in :strict mode,
1051
+ # raise it as a ValidationError.
1052
+ # @param message [String] the violation description
1053
+ # @return [void]
1054
+ # @raise [MCPClient::Errors::ValidationError] in :strict mode
1055
+ def handle_structured_content_violation(message)
1056
+ @logger.warn(message)
1057
+ raise MCPClient::Errors::ValidationError, message if @validate_structured_content == :strict
1058
+ end
1059
+
775
1060
  def find_server_for_tool(tool)
776
1061
  servers.find do |server|
777
1062
  server.list_tools.any? { |t| t.name == tool.name }
@@ -817,7 +1102,10 @@ module MCPClient
817
1102
  # @param tool_name [String] the tool name (for the message)
818
1103
  # @raise [MCPClient::Errors::ToolCallError] if the tool requires task execution
819
1104
  def reject_task_required!(tool, tool_name)
820
- return unless tool.task_required?
1105
+ # Tasks Tool-Level Negotiation rule 1: without tasks.requests.tools.call
1106
+ # in the server capabilities, taskSupport is disregarded entirely and
1107
+ # the tool is invoked as a plain call.
1108
+ return unless tool.task_required? && server_supports_task_tool_call?(tool.server)
821
1109
 
822
1110
  raise MCPClient::Errors::ToolCallError,
823
1111
  "Tool '#{tool_name}' requires task-augmented execution; call it with call_tool_as_task instead"
@@ -934,13 +1222,23 @@ module MCPClient
934
1222
  # @param params [Hash] the elicitation parameters
935
1223
  # @return [Hash] the elicitation response
936
1224
  def handle_elicitation_request(_request_id, params)
937
- # If no handler is configured, decline the request
1225
+ mode = params['mode'] || 'form'
1226
+ # MCP 2025-11-25: requests with a mode not declared in client
1227
+ # capabilities MUST be rejected with -32602 (Invalid params). This check
1228
+ # precedes everything else — an undeclared mode is -32602 even when no
1229
+ # handler is configured.
1230
+ unless SUPPORTED_ELICITATION_MODES.include?(mode)
1231
+ @logger.warn("Rejecting elicitation request with unsupported mode '#{mode}'")
1232
+ return jsonrpc_error_result(-32_602, "Elicitation mode '#{mode}' is not supported")
1233
+ end
1234
+
1235
+ # Without a handler there is no user to interact with: answer with a
1236
+ # JSON-RPC error rather than fabricating a user "decline".
938
1237
  unless @elicitation_handler
939
- @logger.warn('Received elicitation request but no handler configured, declining')
940
- return { 'action' => 'decline' }
1238
+ @logger.warn('Received elicitation request but no elicitation handler is configured')
1239
+ return jsonrpc_error_result(-32_601, 'Elicitation not supported: no elicitation handler configured')
941
1240
  end
942
1241
 
943
- mode = params['mode'] || 'form'
944
1242
  message = params['message']
945
1243
 
946
1244
  begin
@@ -952,12 +1250,25 @@ module MCPClient
952
1250
 
953
1251
  format_elicitation_response(result, params)
954
1252
  rescue StandardError => e
1253
+ # Same reasoning as the sampling path: the handler's exception text is
1254
+ # host-internal and must not cross to the server. Because this rescue
1255
+ # runs inside the client, the transports' constant-message rescues
1256
+ # never see it — so it has to be constant here.
955
1257
  @logger.error("Elicitation handler error: #{e.message}")
956
1258
  @logger.debug(e.backtrace.join("\n"))
957
- { 'action' => 'decline' }
1259
+ jsonrpc_error_result(-32_603, 'Elicitation handler error')
958
1260
  end
959
1261
  end
960
1262
 
1263
+ # Build an error-shaped handler result that transports turn into a
1264
+ # JSON-RPC error response (mirroring the sampling error path).
1265
+ # @param code [Integer] JSON-RPC error code
1266
+ # @param message [String] error message
1267
+ # @return [Hash] error result
1268
+ def jsonrpc_error_result(code, message)
1269
+ { 'error' => { 'code' => code, 'message' => message } }
1270
+ end
1271
+
961
1272
  # Handle form mode elicitation (MCP 2025-11-25)
962
1273
  # @param params [Hash] the elicitation parameters
963
1274
  # @param message [String] the human-readable message
@@ -1012,47 +1323,65 @@ module MCPClient
1012
1323
  # @param params [Hash] original request params (for schema validation)
1013
1324
  # @return [Hash] formatted response
1014
1325
  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
1326
+ response = normalize_elicitation_result(result)
1327
+
1328
+ # Per the ElicitResult schema, content is only present when the action
1329
+ # is accept and the mode was form; it is omitted for decline/cancel and
1330
+ # for out-of-band (url) mode responses.
1331
+ response.delete('content') if response['action'] != 'accept' || (params['mode'] || 'form') == 'url'
1332
+
1333
+ # ElicitResult.content is an object mapping property names to primitive
1334
+ # values a scalar cannot be transmitted.
1335
+ if response.key?('content') && !response['content'].is_a?(Hash)
1336
+ @logger.warn("Elicitation handler returned non-object content (#{response['content'].class})")
1337
+ return jsonrpc_error_result(-32_603, 'Elicitation content must be an object of primitive values')
1338
+ end
1032
1339
 
1033
- # Validate content against schema for form mode accept responses
1034
- validate_elicitation_content(response, params)
1340
+ # Validate content against schema for form mode accept responses; do not
1341
+ # transmit content that violates the requestedSchema (spec SHOULD).
1342
+ errors = validate_elicitation_content(response, params)
1343
+ unless errors.empty?
1344
+ @logger.warn("Elicitation content validation failed: #{errors.join('; ')}")
1345
+ return jsonrpc_error_result(-32_603, "Elicitation content failed schema validation: #{errors.join('; ')}")
1346
+ end
1035
1347
 
1036
1348
  response
1037
1349
  end
1038
1350
 
1351
+ # Normalize a handler's return value into a string-keyed ElicitResult
1352
+ # shape, so mixed or symbol keys cannot bypass content handling.
1353
+ # @param result [Object] handler result
1354
+ # @return [Hash] normalized response with string keys
1355
+ def normalize_elicitation_result(result)
1356
+ case result
1357
+ when Hash
1358
+ action = result['action'] || result[:action]
1359
+ return { 'action' => 'accept', 'content' => result } unless action
1360
+
1361
+ content = result.key?('content') || result.key?(:content) ? (result['content'] || result[:content]) : nil
1362
+ meta = result['_meta'] || result[:_meta]
1363
+ normalised_action_response({ 'action' => action.to_s, 'content' => content, '_meta' => meta }.compact)
1364
+ when nil
1365
+ { 'action' => 'cancel' }
1366
+ else
1367
+ { 'action' => 'accept', 'content' => result }
1368
+ end
1369
+ end
1370
+
1039
1371
  # Validate elicitation response content against the requestedSchema
1040
1372
  # @param response [Hash] the formatted response
1041
1373
  # @param params [Hash] original request params
1042
- # @return [void]
1374
+ # @return [Array<String>] validation errors (empty when conforming or not applicable)
1043
1375
  def validate_elicitation_content(response, params)
1044
- return unless response['action'] == 'accept' && response['content'].is_a?(Hash)
1376
+ return [] unless response['action'] == 'accept' && response['content'].is_a?(Hash)
1045
1377
 
1046
1378
  mode = params['mode'] || 'form'
1047
- return unless mode == 'form'
1379
+ return [] unless mode == 'form'
1048
1380
 
1049
1381
  schema = params['requestedSchema'] || params['schema']
1050
- return unless schema.is_a?(Hash)
1382
+ return [] unless schema.is_a?(Hash)
1051
1383
 
1052
- errors = ElicitationValidator.validate_content(response['content'], schema)
1053
- return if errors.empty?
1054
-
1055
- @logger.warn("Elicitation content validation warnings: #{errors.join('; ')}")
1384
+ ElicitationValidator.validate_content(response['content'], schema)
1056
1385
  end
1057
1386
 
1058
1387
  # Ensure the action value conforms to MCP spec (accept, decline, cancel)
@@ -1095,10 +1424,18 @@ module MCPClient
1095
1424
  # @return [void]
1096
1425
  def notify_roots_changed
1097
1426
  @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}")
1427
+ # Only notify sessions where the roots capability could be declared:
1428
+ # MCP forbids using capabilities that were not negotiated, and
1429
+ # transports without a server-request channel (plain HTTP) never
1430
+ # declare roots.
1431
+ next unless server.respond_to?(:on_roots_list_request)
1432
+
1433
+ begin
1434
+ server.rpc_notify('notifications/roots/list_changed', {})
1435
+ rescue StandardError => e
1436
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
1437
+ @logger.warn("[#{server_id}] Failed to send roots/list_changed notification: #{e.message}")
1438
+ end
1102
1439
  end
1103
1440
  end
1104
1441
 
@@ -1107,32 +1444,46 @@ module MCPClient
1107
1444
  # @param params [Hash] the sampling parameters
1108
1445
  # @return [Hash] the sampling response (role, content, model, stopReason)
1109
1446
  def handle_sampling_request(_request_id, params)
1110
- # If no handler is configured, return an error
1447
+ # Without a handler the sampling capability was never declared, so the
1448
+ # request targets an unsupported method: answer -32601 (Method not
1449
+ # found) rather than -1, which sampling.mdx § Error Handling reserves
1450
+ # for "User rejected sampling request".
1111
1451
  unless @sampling_handler
1112
- @logger.warn('Received sampling request but no handler configured')
1113
- return { 'error' => { 'code' => -1, 'message' => 'Sampling not supported' } }
1452
+ @logger.warn('Received sampling request but no sampling handler is configured')
1453
+ return jsonrpc_error_result(-32_601, 'Sampling not supported: no sampling handler configured')
1454
+ end
1455
+
1456
+ # SEP-1577 (schema.ts CreateMessageRequestParams.tools/.toolChoice):
1457
+ # "The client MUST return an error if this field is provided but
1458
+ # ClientCapabilities.sampling.tools is not declared." -32602 is the
1459
+ # Invalid params code used by sampling.mdx § Error Handling.
1460
+ if (params.key?('tools') || params.key?('toolChoice')) && !@sampling_supports_tools
1461
+ @logger.warn('Rejecting tool-enabled sampling request: sampling.tools capability not declared')
1462
+ return jsonrpc_error_result(-32_602,
1463
+ 'Invalid params: tools/toolChoice provided but the sampling.tools ' \
1464
+ 'capability was not declared')
1114
1465
  end
1115
1466
 
1116
1467
  messages = params['messages'] || []
1117
1468
  model_preferences = normalize_model_preferences(params['modelPreferences'])
1118
1469
  system_prompt = params['systemPrompt']
1119
1470
  max_tokens = params['maxTokens']
1120
- include_context = params['includeContext']
1121
- temperature = params['temperature']
1122
- stop_sequences = params['stopSequences']
1123
- metadata = params['metadata']
1124
1471
 
1125
1472
  begin
1126
1473
  # 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)
1474
+ result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
1129
1475
 
1130
1476
  # Validate and format response
1131
1477
  validate_sampling_response(result)
1132
1478
  rescue StandardError => e
1133
1479
  @logger.error("Sampling handler error: #{e.message}")
1134
1480
  @logger.debug(e.backtrace.join("\n"))
1135
- { 'error' => { 'code' => -1, 'message' => "Sampling error: #{e.message}" } }
1481
+ # A handler exception is an internal client failure (-32603), not a
1482
+ # user rejection: sampling.mdx § Error Handling reserves -1 for
1483
+ # "User rejected sampling request". The exception message itself is
1484
+ # host-internal (file paths, connection strings, library internals)
1485
+ # and stays in the local log rather than crossing to the server.
1486
+ jsonrpc_error_result(-32_603, 'Sampling error')
1136
1487
  end
1137
1488
  end
1138
1489
 
@@ -1141,32 +1492,38 @@ module MCPClient
1141
1492
  # @param model_preferences [Hash, nil] normalized model preferences
1142
1493
  # @param system_prompt [String, nil] system prompt
1143
1494
  # @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
1495
+ # @param params [Hash] the complete sampling/createMessage request params;
1496
+ # handlers whose fifth parameter is required, optional, or part of a
1497
+ # rest argument receive this hash verbatim, so they can read
1498
+ # includeContext, temperature, stopSequences, metadata, the SEP-1577
1499
+ # tools/toolChoice fields, _meta, and any future params
1148
1500
  # @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 })
1501
+ def call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
1502
+ args = [messages, model_preferences, system_prompt, max_tokens, params]
1503
+ @sampling_handler.call(*args.first(sampling_handler_arg_count))
1504
+ end
1505
+
1506
+ # Number of the five positional sampling arguments the handler can accept.
1507
+ # Arity alone cannot size variable-arity handlers: lambdas with optional
1508
+ # or rest parameters report a negative arity, and non-lambda procs with
1509
+ # optional parameters report their mandatory minimum as a nonnegative
1510
+ # arity (proc { |m, p = nil, s = nil, t = nil, extra = nil| }.arity == 1).
1511
+ # Normalizing either to the minimum required count would starve the
1512
+ # handler of the raw params (including the SEP-1577 tools/toolChoice
1513
+ # fields), so any handler whose parameters include :opt or :rest entries
1514
+ # (or whose arity is negative) is sized from Proc#parameters instead:
1515
+ # each :req/:opt parameter accepts one argument and a :rest accepts the
1516
+ # full list. Plain fixed-arity handlers keep arity-based sizing.
1517
+ # @return [Integer] how many arguments to pass, capped at 5
1518
+ def sampling_handler_arg_count
1519
+ parameters = @sampling_handler.parameters
1520
+ return 5 if parameters.any? { |type, _name| type == :rest }
1521
+
1522
+ if @sampling_handler.arity.negative? || parameters.any? { |type, _name| type == :opt }
1523
+ return [parameters.count { |type, _name| %i[req opt].include?(type) }, 5].min
1169
1524
  end
1525
+
1526
+ [@sampling_handler.arity, 5].min
1170
1527
  end
1171
1528
 
1172
1529
  # Normalize and validate modelPreferences from sampling request (MCP 2025-11-25)
@@ -1203,7 +1560,11 @@ module MCPClient
1203
1560
  # @param result [Hash] the result from the sampling handler
1204
1561
  # @return [Hash] validated sampling response
1205
1562
  def validate_sampling_response(result)
1206
- return { 'error' => { 'code' => -1, 'message' => 'Sampling rejected' } } if result.nil?
1563
+ # A nil handler result is the host's rejection signal; -1 is the code
1564
+ # sampling.mdx § Error Handling assigns to "User rejected sampling
1565
+ # request" (internal failures use -32603 instead, see
1566
+ # handle_sampling_request).
1567
+ return jsonrpc_error_result(-1, 'Sampling rejected') if result.nil?
1207
1568
 
1208
1569
  # Convert symbol keys to string keys
1209
1570
  result = result.transform_keys(&:to_s) if result.is_a?(Hash) && result.keys.first.is_a?(Symbol)
@@ -1218,15 +1579,27 @@ module MCPClient
1218
1579
  }
1219
1580
  end
1220
1581
 
1221
- # Set defaults for missing fields
1582
+ # Set defaults for missing fields. ToolUseContent blocks (SEP-1577) are
1583
+ # passed through verbatim; when the handler omits stopReason for them,
1584
+ # default to "toolUse" per the CreateMessageResult stopReason values.
1222
1585
  result['role'] ||= 'assistant'
1223
1586
  result['model'] ||= 'unknown'
1224
- result['stopReason'] ||= 'endTurn'
1587
+ result['stopReason'] ||= tool_use_content?(result['content']) ? 'toolUse' : 'endTurn'
1225
1588
 
1226
1589
  # Normalize content if it's a string
1227
1590
  result['content'] = { 'type' => 'text', 'text' => result['content'] } if result['content'].is_a?(String)
1228
1591
 
1229
1592
  result
1230
1593
  end
1594
+
1595
+ # Whether sampling response content contains ToolUseContent blocks (MCP 2025-11-25 / SEP-1577)
1596
+ # @param content [Object] the content field of a CreateMessageResult
1597
+ # @return [Boolean] true when any content block has type "tool_use"
1598
+ def tool_use_content?(content)
1599
+ blocks = content.is_a?(Array) ? content : [content]
1600
+ blocks.any? do |block|
1601
+ block.is_a?(Hash) && (block['type'] == 'tool_use' || block[:type] == 'tool_use')
1602
+ end
1603
+ end
1231
1604
  end
1232
1605
  end