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