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