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.
- checksums.yaml +4 -4
- data/README.md +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- 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 +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
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,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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
57
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
501
|
-
#
|
|
502
|
-
#
|
|
503
|
-
#
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
#
|
|
507
|
-
# @
|
|
508
|
-
# @
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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('
|
|
516
|
-
MCPClient::Task.
|
|
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
|
|
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', {
|
|
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
|
-
|
|
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
|
-
#
|
|
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)
|
|
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', {
|
|
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
|
-
|
|
561
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
777
|
-
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')
|
|
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
|
-
|
|
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 =
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
982
|
-
#
|
|
983
|
-
#
|
|
984
|
-
#
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
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
|