ruby-mcp-client 1.0.1 → 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,21 +25,53 @@ 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)
30
- @logger = logger || Logger.new($stdout, level: Logger::WARN)
31
- @logger.progname = self.class.name
32
- @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
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
55
+ # Preserve a caller-supplied logger's formatter (only tag progname), and
56
+ # install the default formatter solely on a logger we create ourselves.
57
+ # Overwriting the formatter of an application's logger would silently
58
+ # reformat every log line it emits elsewhere.
59
+ if logger
60
+ @logger = logger
61
+ @logger.progname = self.class.name
62
+ else
63
+ @logger = Logger.new($stdout, level: Logger::WARN)
64
+ @logger.progname = self.class.name
65
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
66
+ end
33
67
  @servers = mcp_server_configs.map do |config|
34
68
  @logger.debug("Creating server with config: #{config.inspect}")
35
69
  MCPClient::ServerFactory.create(config, logger: @logger)
36
70
  end
37
71
  @tool_cache = {}
72
+ # Active progressToken -> callback registrations (MCP progress utility)
73
+ @progress_callbacks = {}
74
+ @progress_mutex = Mutex.new
38
75
  @prompt_cache = {}
39
76
  @resource_cache = {}
40
77
  # JSON-RPC notification listeners
@@ -43,24 +80,37 @@ module MCPClient
43
80
  @elicitation_handler = elicitation_handler
44
81
  # Sampling handler (MCP 2025-11-25)
45
82
  @sampling_handler = sampling_handler
83
+ # Whether the sampling handler supports tool use (SEP-1577)
84
+ @sampling_supports_tools = sampling_supports_tools
46
85
  # Roots (MCP 2025-06-18)
47
86
  @roots = normalize_roots(roots)
48
87
  # Register default and user-defined notification handlers on each server
49
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=)
50
91
  server.on_notification do |method, params|
51
92
  # Default notification processing (e.g., cache invalidation, logging)
52
93
  process_notification(server, method, params)
53
94
  # Invoke user-defined listeners
54
95
  @notification_listeners.each { |cb| cb.call(server, method, params) }
55
96
  end
56
- # Register elicitation handler on each server
57
- 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)
58
102
  server.on_elicitation_request(&method(:handle_elicitation_request))
59
103
  end
60
- # 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.
61
106
  server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
62
- # Register sampling handler on each server (MCP 2025-11-25)
63
- 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)
64
114
  end
65
115
  end
66
116
 
@@ -250,49 +300,35 @@ module MCPClient
250
300
  # @param parameters [Hash] the parameters to pass to the tool
251
301
  # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
252
302
  # @return [Object] the result of the tool invocation
253
- def call_tool(tool_name, parameters, server: nil)
254
- tools = list_tools
255
-
256
- if server
257
- # Use the specified server
258
- srv = select_server(server)
259
- # Find the tool on this specific server
260
- tool = tools.find { |t| t.name == tool_name && t.server == srv }
261
- unless tool
262
- raise MCPClient::Errors::ToolNotFound,
263
- "Tool '#{tool_name}' not found on server '#{srv.name || srv.class.name}'"
264
- end
265
- else
266
- # Find the tool across all servers
267
- matching_tools = tools.select { |t| t.name == tool_name }
268
-
269
- if matching_tools.empty?
270
- raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found"
271
- elsif matching_tools.size > 1
272
- # If multiple matches, disambiguate with server names
273
- server_names = matching_tools.map { |t| t.server&.name || 'unnamed' }
274
- raise MCPClient::Errors::AmbiguousToolName,
275
- "Multiple tools named '#{tool_name}' found across servers (#{server_names.join(', ')}). " \
276
- "Please specify a server using the 'server' parameter."
277
- end
278
-
279
- tool = matching_tools.first
280
- end
303
+ def call_tool(tool_name, parameters, server: nil, progress: nil)
304
+ tool = resolve_tool(tool_name, server: server)
281
305
 
282
306
  # Validate parameters against tool schema
283
307
  validate_params!(tool, parameters)
308
+ reject_task_required!(tool, tool_name)
284
309
 
285
310
  # Use the tool's associated server
286
311
  server = tool.server
287
312
  raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
288
313
 
289
- 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
290
320
  server.call_tool(tool_name, parameters)
291
321
  rescue MCPClient::Errors::ConnectionError => e
292
322
  # Add server identity information to the error for better context
293
323
  server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
294
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
295
329
  end
330
+
331
+ validate_structured_content!(tool, result)
296
332
  end
297
333
 
298
334
  # Convert MCP tools to OpenAI function specifications
@@ -396,36 +432,11 @@ module MCPClient
396
432
  # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
397
433
  # @return [Enumerator] streaming enumerator or single-value enumerator
398
434
  def call_tool_streaming(tool_name, parameters, server: nil)
399
- tools = list_tools
400
-
401
- if server
402
- # Use the specified server
403
- srv = select_server(server)
404
- # Find the tool on this specific server
405
- tool = tools.find { |t| t.name == tool_name && t.server == srv }
406
- unless tool
407
- raise MCPClient::Errors::ToolNotFound,
408
- "Tool '#{tool_name}' not found on server '#{srv.name || srv.class.name}'"
409
- end
410
- else
411
- # Find the tool across all servers
412
- matching_tools = tools.select { |t| t.name == tool_name }
413
-
414
- if matching_tools.empty?
415
- raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found"
416
- elsif matching_tools.size > 1
417
- # If multiple matches, disambiguate with server names
418
- server_names = matching_tools.map { |t| t.server&.name || 'unnamed' }
419
- raise MCPClient::Errors::AmbiguousToolName,
420
- "Multiple tools named '#{tool_name}' found across servers (#{server_names.join(', ')}). " \
421
- "Please specify a server using the 'server' parameter."
422
- end
423
-
424
- tool = matching_tools.first
425
- end
435
+ tool = resolve_tool(tool_name, server: server)
426
436
 
427
437
  # Validate parameters against tool schema
428
438
  validate_params!(tool, parameters)
439
+ reject_task_required!(tool, tool_name)
429
440
 
430
441
  # Use the tool's associated server
431
442
  server = tool.server
@@ -468,9 +479,13 @@ module MCPClient
468
479
  # @param params [Hash] parameters for the request
469
480
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
470
481
  # @return [Object] result from the JSON-RPC response
471
- def send_rpc(method, params: {}, server: nil)
482
+ def send_rpc(method, params: {}, server: nil, timeout: nil)
472
483
  srv = select_server(server)
473
- 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)
474
489
  end
475
490
 
476
491
  # Send a raw JSON-RPC notification to a server (no response expected)
@@ -497,32 +512,59 @@ module MCPClient
497
512
  srv.complete(ref: ref, argument: argument, context: context)
498
513
  end
499
514
 
500
- # Create a new task on a server (MCP 2025-11-25)
501
- # Tasks represent long-running operations that can report progress
502
- # @param method [String] the method to execute as a task
503
- # @param params [Hash] parameters for the task method
504
- # @param progress_token [String, nil] optional token for receiving progress notifications
505
- # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
506
- # @return [MCPClient::Task] the created task
507
- # @raise [MCPClient::Errors::ServerNotFound] if no server is available
508
- # @raise [MCPClient::Errors::TaskError] if task creation fails
509
- def create_task(method, params: {}, progress_token: nil, server: nil)
510
- srv = select_server(server)
511
- rpc_params = { method: method, params: params }
512
- rpc_params[:progressToken] = progress_token if progress_token
515
+ # Call a tool as a task (task-augmented tools/call, MCP 2025-11-25).
516
+ #
517
+ # Instead of blocking for the result, the server accepts the request and
518
+ # immediately returns a task handle; the actual result is retrieved later
519
+ # via {#get_task_result} once the task reaches a terminal status. The server
520
+ # must advertise the tasks.requests.tools.call capability, and the tool must
521
+ # declare execution.taskSupport of 'optional' or 'required'.
522
+ # @param tool_name [String] the name of the tool to call
523
+ # @param parameters [Hash] the parameters to pass to the tool
524
+ # @param ttl [Integer, nil] optional requested task lifetime in milliseconds
525
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
526
+ # @return [MCPClient::Task] the created task (status typically 'working')
527
+ # @raise [MCPClient::Errors::ToolNotFound] if the tool is not found
528
+ # @raise [MCPClient::Errors::ValidationError] if required parameters are missing
529
+ # @raise [MCPClient::Errors::TaskError] if the server or tool does not support tasks, or creation fails
530
+ def call_tool_as_task(tool_name, parameters, ttl: nil, server: nil)
531
+ tool = resolve_tool(tool_name, server: server)
532
+ validate_params!(tool, parameters)
533
+
534
+ srv = tool.server
535
+ raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless srv
536
+
537
+ unless server_supports_task_tool_call?(srv)
538
+ raise MCPClient::Errors::TaskError,
539
+ 'Server does not support task-augmented tools/call (no tasks.requests.tools.call capability)'
540
+ end
541
+ unless tool.supports_task?
542
+ raise MCPClient::Errors::TaskError,
543
+ "Tool '#{tool_name}' does not support task execution (execution.taskSupport is forbidden/unset)"
544
+ end
545
+
546
+ task_params = {}
547
+ task_params[:ttl] = ttl if ttl
548
+ # Keep _meta (string or symbol key) as a top-level request field rather
549
+ # than a tool argument, so request metadata is preserved and does not fail
550
+ # tool input-schema validation.
551
+ meta_key = [:_meta, '_meta'].find { |k| parameters.key?(k) }
552
+ arguments = meta_key ? parameters.reject { |k, _| k == meta_key } : parameters
553
+ rpc_params = { name: tool_name, arguments: arguments, task: task_params }
554
+ rpc_params[:_meta] = parameters[meta_key] if meta_key
513
555
 
514
556
  begin
515
- result = srv.rpc_request('tasks/create', rpc_params)
516
- MCPClient::Task.from_json(result, server: srv)
557
+ result = srv.rpc_request('tools/call', rpc_params)
558
+ MCPClient::Task.from_create_result(result, server: srv)
517
559
  rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
518
- raise MCPClient::Errors::TaskError, "Error creating task: #{e.message}"
560
+ raise MCPClient::Errors::TaskError, "Error creating task for tool '#{tool_name}': #{e.message}"
519
561
  end
520
562
  end
521
563
 
522
- # Get the current state of a task (MCP 2025-11-25)
564
+ # Get the current state of a task (tasks/get, MCP 2025-11-25)
523
565
  # @param task_id [String] the ID of the task to query
524
566
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
525
- # @return [MCPClient::Task] the task with current state
567
+ # @return [MCPClient::Task] the task with current status
526
568
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
527
569
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
528
570
  # @raise [MCPClient::Errors::TaskError] if retrieving the task fails
@@ -530,38 +572,84 @@ module MCPClient
530
572
  srv = select_server(server)
531
573
 
532
574
  begin
533
- result = srv.rpc_request('tasks/get', { id: task_id })
575
+ result = srv.rpc_request('tasks/get', { taskId: task_id })
534
576
  MCPClient::Task.from_json(result, server: srv)
535
577
  rescue MCPClient::Errors::ServerError => e
536
- if e.message.include?('not found') || e.message.include?('unknown task')
537
- raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
538
- end
539
-
540
- raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
578
+ raise task_error_from(e, task_id, 'getting')
541
579
  rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
542
580
  raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
543
581
  end
544
582
  end
545
583
 
546
- # Cancel a running task (MCP 2025-11-25)
584
+ # Retrieve the result of a completed task (tasks/result, MCP 2025-11-25).
585
+ # Returns exactly what the underlying request would have returned (e.g. a
586
+ # CallToolResult hash with 'content'/'isError'/'structuredContent'); it is
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.
594
+ # @param task_id [String] the ID of the task
595
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
596
+ # @return [Object] the underlying task result
597
+ # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
598
+ # @raise [MCPClient::Errors::TaskError] if retrieval fails
599
+ def get_task_result(task_id, server: nil)
600
+ srv = select_server(server)
601
+
602
+ begin
603
+ srv.rpc_request('tasks/result', { taskId: task_id })
604
+ rescue MCPClient::Errors::ServerError => e
605
+ raise task_error_from(e, task_id, 'getting result for')
606
+ rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
607
+ raise MCPClient::Errors::TaskError, "Error getting result for task '#{task_id}': #{e.message}"
608
+ end
609
+ end
610
+
611
+ # List tasks known to a server (tasks/list, paginated, MCP 2025-11-25)
612
+ # @param cursor [String, nil] optional pagination cursor
613
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
614
+ # @return [Hash] { tasks: Array<MCPClient::Task>, next_cursor: String, nil }
615
+ # @raise [MCPClient::Errors::TaskError] if listing fails
616
+ def list_tasks(cursor: nil, server: nil)
617
+ srv = select_server(server)
618
+ ensure_task_capability!(srv, 'list')
619
+
620
+ params = cursor ? { cursor: cursor } : {}
621
+
622
+ begin
623
+ result = srv.rpc_request('tasks/list', params) || {}
624
+ tasks = (result['tasks'] || []).map { |t| MCPClient::Task.from_json(t, server: srv) }
625
+ { tasks: tasks, next_cursor: result['nextCursor'] }
626
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
627
+ raise MCPClient::Errors::TaskError, "Error listing tasks: #{e.message}"
628
+ end
629
+ end
630
+
631
+ # Cancel a task (tasks/cancel, MCP 2025-11-25)
547
632
  # @param task_id [String] the ID of the task to cancel
548
633
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
549
- # @return [MCPClient::Task] the task with updated (cancelled) state
634
+ # @return [MCPClient::Task] the task with updated (cancelled) status
550
635
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
551
636
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
552
- # @raise [MCPClient::Errors::TaskError] if cancellation fails
637
+ # @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task)
553
638
  def cancel_task(task_id, server: nil)
554
639
  srv = select_server(server)
640
+ ensure_task_capability!(srv, 'cancel')
555
641
 
556
642
  begin
557
- result = srv.rpc_request('tasks/cancel', { id: task_id })
643
+ result = srv.rpc_request('tasks/cancel', { taskId: task_id })
558
644
  MCPClient::Task.from_json(result, server: srv)
559
645
  rescue MCPClient::Errors::ServerError => e
560
- if e.message.include?('not found') || e.message.include?('unknown task')
561
- raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
646
+ # A terminal task cannot be cancelled (-32602); that is an error, not a
647
+ # missing task, so keep it as a TaskError.
648
+ if e.message.match?(/terminal/i)
649
+ raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
562
650
  end
563
651
 
564
- raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
652
+ raise task_error_from(e, task_id, 'cancelling')
565
653
  rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
566
654
  raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
567
655
  end
@@ -574,11 +662,57 @@ module MCPClient
574
662
  # @return [Array<Hash>] results from servers
575
663
  # @raise [MCPClient::Errors::ServerError] if server returns an error
576
664
  def log_level=(level)
577
- @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
578
678
  end
579
679
 
580
680
  private
581
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
+
582
716
  # Process incoming JSON-RPC notifications with default handlers
583
717
  # @param server [MCPClient::ServerBase] the server that emitted the notification
584
718
  # @param method [String] JSON-RPC notification method
@@ -601,6 +735,19 @@ module MCPClient
601
735
  when 'notifications/message'
602
736
  # MCP 2025-06-18: Handle logging messages from server
603
737
  handle_log_message(server_id, params)
738
+ when 'notifications/tasks/status'
739
+ # MCP 2025-11-25: task status update (params are a flat Task)
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)
604
751
  else
605
752
  # Log unknown notification types for debugging purposes
606
753
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
@@ -611,6 +758,66 @@ module MCPClient
611
758
  # @param server_id [String] server identifier for log prefix
612
759
  # @param params [Hash] log message params (level, logger, data)
613
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
+
614
821
  def handle_log_message(server_id, params)
615
822
  level = params['level'] || 'info'
616
823
  logger_name = params['logger']
@@ -694,12 +901,167 @@ module MCPClient
694
901
  raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
695
902
  end
696
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
+
697
971
  def find_server_for_tool(tool)
698
972
  servers.find do |server|
699
973
  server.list_tools.any? { |t| t.name == tool.name }
700
974
  end
701
975
  end
702
976
 
977
+ # Resolve a tool by name (optionally scoped to a server), raising the same
978
+ # not-found / ambiguity errors as call_tool.
979
+ # @param tool_name [String] the tool name
980
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server selector
981
+ # @return [MCPClient::Tool] the resolved tool
982
+ # @raise [MCPClient::Errors::ToolNotFound, MCPClient::Errors::AmbiguousToolName]
983
+ def resolve_tool(tool_name, server: nil)
984
+ tools = list_tools
985
+
986
+ if server
987
+ srv = select_server(server)
988
+ tool = tools.find { |t| t.name == tool_name && t.server == srv }
989
+ unless tool
990
+ raise MCPClient::Errors::ToolNotFound,
991
+ "Tool '#{tool_name}' not found on server '#{srv.name || srv.class.name}'"
992
+ end
993
+ return tool
994
+ end
995
+
996
+ matching_tools = tools.select { |t| t.name == tool_name }
997
+ if matching_tools.empty?
998
+ raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found"
999
+ elsif matching_tools.size > 1
1000
+ server_names = matching_tools.map { |t| t.server&.name || 'unnamed' }
1001
+ raise MCPClient::Errors::AmbiguousToolName,
1002
+ "Multiple tools named '#{tool_name}' found across servers (#{server_names.join(', ')}). " \
1003
+ "Please specify a server using the 'server' parameter."
1004
+ end
1005
+
1006
+ matching_tools.first
1007
+ end
1008
+
1009
+ # Reject a plain (synchronous) call for a tool whose execution.taskSupport is
1010
+ # 'required'. A compliant server would reject a non-task-augmented tools/call
1011
+ # for such a tool, so fail fast and point the caller at call_tool_as_task.
1012
+ # @param tool [MCPClient::Tool] the resolved tool
1013
+ # @param tool_name [String] the tool name (for the message)
1014
+ # @raise [MCPClient::Errors::ToolCallError] if the tool requires task execution
1015
+ def reject_task_required!(tool, tool_name)
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)
1020
+
1021
+ raise MCPClient::Errors::ToolCallError,
1022
+ "Tool '#{tool_name}' requires task-augmented execution; call it with call_tool_as_task instead"
1023
+ end
1024
+
1025
+ # Whether a server advertised support for task-augmented tools/call, i.e.
1026
+ # capabilities.tasks.requests.tools.call.
1027
+ # @param srv [MCPClient::ServerBase] the server
1028
+ # @return [Boolean]
1029
+ def server_supports_task_tool_call?(srv)
1030
+ caps = srv.respond_to?(:capabilities) ? srv.capabilities : nil
1031
+ return false unless caps.is_a?(Hash)
1032
+
1033
+ tasks = caps['tasks'] || caps[:tasks]
1034
+ requests = tasks && (tasks['requests'] || tasks[:requests])
1035
+ tools = requests && (requests['tools'] || requests[:tools])
1036
+ call = tools && (tools['call'] || tools[:call])
1037
+ !call.nil?
1038
+ end
1039
+
1040
+ # Map a ServerError from a task operation to TaskNotFound or TaskError.
1041
+ # @param error [MCPClient::Errors::ServerError] the server error
1042
+ # @param task_id [String] the task id
1043
+ # @param action [String] a verb phrase for the error message (e.g. 'getting')
1044
+ # @return [MCPClient::Errors::TaskNotFound, MCPClient::Errors::TaskError]
1045
+ def task_error_from(error, task_id, action)
1046
+ if error.message.match?(/not found|unknown task|expired/i)
1047
+ return MCPClient::Errors::TaskNotFound.new("Task '#{task_id}' not found")
1048
+ end
1049
+
1050
+ MCPClient::Errors::TaskError.new("Error #{action} task '#{task_id}': #{error.message}")
1051
+ end
1052
+
1053
+ # Handle a notifications/tasks/status notification (MCP 2025-11-25).
1054
+ # The params are a flat Task.
1055
+ # @param server_id [String] server identifier for the log prefix
1056
+ # @param params [Hash] the flat task params
1057
+ # @return [void]
1058
+ def handle_task_status_notification(server_id, params)
1059
+ task = MCPClient::Task.from_json(params)
1060
+ logger.info("[#{server_id}] Task #{task.task_id} status: #{task.status}")
1061
+ rescue StandardError => e
1062
+ logger.debug("[#{server_id}] Failed to parse task status notification: #{e.message}")
1063
+ end
1064
+
703
1065
  # Generate a cache key for server-specific items
704
1066
  # @param server [MCPClient::ServerBase] the server
705
1067
  # @param item_id [String] the item identifier (name or URI)
@@ -771,13 +1133,23 @@ module MCPClient
771
1133
  # @param params [Hash] the elicitation parameters
772
1134
  # @return [Hash] the elicitation response
773
1135
  def handle_elicitation_request(_request_id, params)
774
- # 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".
775
1148
  unless @elicitation_handler
776
- @logger.warn('Received elicitation request but no handler configured, declining')
777
- 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')
778
1151
  end
779
1152
 
780
- mode = params['mode'] || 'form'
781
1153
  message = params['message']
782
1154
 
783
1155
  begin
@@ -791,10 +1163,19 @@ module MCPClient
791
1163
  rescue StandardError => e
792
1164
  @logger.error("Elicitation handler error: #{e.message}")
793
1165
  @logger.debug(e.backtrace.join("\n"))
794
- { 'action' => 'decline' }
1166
+ jsonrpc_error_result(-32_603, "Elicitation handler error: #{e.message}")
795
1167
  end
796
1168
  end
797
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
+
798
1179
  # Handle form mode elicitation (MCP 2025-11-25)
799
1180
  # @param params [Hash] the elicitation parameters
800
1181
  # @param message [String] the human-readable message
@@ -849,47 +1230,65 @@ module MCPClient
849
1230
  # @param params [Hash] original request params (for schema validation)
850
1231
  # @return [Hash] formatted response
851
1232
  def format_elicitation_response(result, params)
852
- response = case result
853
- when Hash
854
- if result['action']
855
- normalised_action_response(result)
856
- elsif result[:action]
857
- {
858
- 'action' => result[:action].to_s,
859
- 'content' => result[:content]
860
- }.compact.then { |payload| normalised_action_response(payload) }
861
- else
862
- { 'action' => 'accept', 'content' => result }
863
- end
864
- when nil
865
- { 'action' => 'cancel' }
866
- else
867
- { 'action' => 'accept', 'content' => result }
868
- 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
869
1246
 
870
- # Validate content against schema for form mode accept responses
871
- 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
872
1254
 
873
1255
  response
874
1256
  end
875
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
+
876
1278
  # Validate elicitation response content against the requestedSchema
877
1279
  # @param response [Hash] the formatted response
878
1280
  # @param params [Hash] original request params
879
- # @return [void]
1281
+ # @return [Array<String>] validation errors (empty when conforming or not applicable)
880
1282
  def validate_elicitation_content(response, params)
881
- return unless response['action'] == 'accept' && response['content'].is_a?(Hash)
1283
+ return [] unless response['action'] == 'accept' && response['content'].is_a?(Hash)
882
1284
 
883
1285
  mode = params['mode'] || 'form'
884
- return unless mode == 'form'
1286
+ return [] unless mode == 'form'
885
1287
 
886
1288
  schema = params['requestedSchema'] || params['schema']
887
- return unless schema.is_a?(Hash)
888
-
889
- errors = ElicitationValidator.validate_content(response['content'], schema)
890
- return if errors.empty?
1289
+ return [] unless schema.is_a?(Hash)
891
1290
 
892
- @logger.warn("Elicitation content validation warnings: #{errors.join('; ')}")
1291
+ ElicitationValidator.validate_content(response['content'], schema)
893
1292
  end
894
1293
 
895
1294
  # Ensure the action value conforms to MCP spec (accept, decline, cancel)
@@ -932,10 +1331,18 @@ module MCPClient
932
1331
  # @return [void]
933
1332
  def notify_roots_changed
934
1333
  @servers.each do |server|
935
- server.rpc_notify('notifications/roots/list_changed', {})
936
- rescue StandardError => e
937
- server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
938
- @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
939
1346
  end
940
1347
  end
941
1348
 
@@ -944,32 +1351,44 @@ module MCPClient
944
1351
  # @param params [Hash] the sampling parameters
945
1352
  # @return [Hash] the sampling response (role, content, model, stopReason)
946
1353
  def handle_sampling_request(_request_id, params)
947
- # 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".
948
1358
  unless @sampling_handler
949
- @logger.warn('Received sampling request but no handler configured')
950
- 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')
951
1372
  end
952
1373
 
953
1374
  messages = params['messages'] || []
954
1375
  model_preferences = normalize_model_preferences(params['modelPreferences'])
955
1376
  system_prompt = params['systemPrompt']
956
1377
  max_tokens = params['maxTokens']
957
- include_context = params['includeContext']
958
- temperature = params['temperature']
959
- stop_sequences = params['stopSequences']
960
- metadata = params['metadata']
961
1378
 
962
1379
  begin
963
1380
  # Call the user-defined handler with parameters based on arity
964
- result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
965
- include_context, temperature, stop_sequences, metadata)
1381
+ result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
966
1382
 
967
1383
  # Validate and format response
968
1384
  validate_sampling_response(result)
969
1385
  rescue StandardError => e
970
1386
  @logger.error("Sampling handler error: #{e.message}")
971
1387
  @logger.debug(e.backtrace.join("\n"))
972
- { '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}")
973
1392
  end
974
1393
  end
975
1394
 
@@ -978,32 +1397,38 @@ module MCPClient
978
1397
  # @param model_preferences [Hash, nil] normalized model preferences
979
1398
  # @param system_prompt [String, nil] system prompt
980
1399
  # @param max_tokens [Integer, nil] max tokens
981
- # @param include_context [String, nil] context inclusion setting
982
- # @param temperature [Float, nil] temperature
983
- # @param stop_sequences [Array, nil] stop sequences
984
- # @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
985
1405
  # @return [Hash] the handler result
986
- def call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
987
- include_context, temperature, stop_sequences, metadata)
988
- arity = @sampling_handler.arity
989
- # Normalize negative arity (optional params) to minimum required args
990
- arity = -(arity + 1) if arity.negative?
991
- case arity
992
- when 0
993
- @sampling_handler.call
994
- when 1
995
- @sampling_handler.call(messages)
996
- when 2
997
- @sampling_handler.call(messages, model_preferences)
998
- when 3
999
- @sampling_handler.call(messages, model_preferences, system_prompt)
1000
- when 4
1001
- @sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
1002
- else
1003
- @sampling_handler.call(messages, model_preferences, system_prompt, max_tokens,
1004
- { 'includeContext' => include_context, 'temperature' => temperature,
1005
- '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
1006
1429
  end
1430
+
1431
+ [@sampling_handler.arity, 5].min
1007
1432
  end
1008
1433
 
1009
1434
  # Normalize and validate modelPreferences from sampling request (MCP 2025-11-25)
@@ -1040,7 +1465,11 @@ module MCPClient
1040
1465
  # @param result [Hash] the result from the sampling handler
1041
1466
  # @return [Hash] validated sampling response
1042
1467
  def validate_sampling_response(result)
1043
- 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?
1044
1473
 
1045
1474
  # Convert symbol keys to string keys
1046
1475
  result = result.transform_keys(&:to_s) if result.is_a?(Hash) && result.keys.first.is_a?(Symbol)
@@ -1055,15 +1484,27 @@ module MCPClient
1055
1484
  }
1056
1485
  end
1057
1486
 
1058
- # 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.
1059
1490
  result['role'] ||= 'assistant'
1060
1491
  result['model'] ||= 'unknown'
1061
- result['stopReason'] ||= 'endTurn'
1492
+ result['stopReason'] ||= tool_use_content?(result['content']) ? 'toolUse' : 'endTurn'
1062
1493
 
1063
1494
  # Normalize content if it's a string
1064
1495
  result['content'] = { 'type' => 'text', 'text' => result['content'] } if result['content'].is_a?(String)
1065
1496
 
1066
1497
  result
1067
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
1068
1509
  end
1069
1510
  end