ruby-mcp-client 2.0.0 → 3.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/OAUTH.md +555 -0
- data/README.md +914 -49
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1367 -311
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -36
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +851 -478
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +481 -6
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +669 -100
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +967 -11
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -2
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +4 -2
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +893 -175
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +12 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
- data/lib/mcp_client/server_sse.rb +310 -76
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
- data/lib/mcp_client/server_stdio.rb +778 -186
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
- data/lib/mcp_client/server_streamable_http.rb +547 -157
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -5
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +115 -19
- metadata +71 -7
data/lib/mcp_client/client.rb
CHANGED
|
@@ -2,39 +2,101 @@
|
|
|
2
2
|
|
|
3
3
|
require 'logger'
|
|
4
4
|
require 'securerandom'
|
|
5
|
+
require_relative 'client/sampling_validation'
|
|
6
|
+
require_relative 'deep_copy'
|
|
7
|
+
require_relative 'client/list_aggregation'
|
|
8
|
+
require_relative 'client/cache_slices'
|
|
9
|
+
require_relative 'client/notification_routing'
|
|
10
|
+
require_relative 'deprecations'
|
|
11
|
+
require_relative 'client/task_support'
|
|
12
|
+
require_relative 'client/task_api'
|
|
5
13
|
|
|
6
14
|
module MCPClient
|
|
7
15
|
# MCP Client for integrating with the Model Context Protocol
|
|
8
16
|
# This is the main entry point for using MCP tools
|
|
9
17
|
class Client
|
|
18
|
+
include SamplingValidation
|
|
19
|
+
include ListAggregation
|
|
20
|
+
include CacheSlices
|
|
21
|
+
include NotificationRouting
|
|
22
|
+
include MCPClient::Client::TaskSupport
|
|
23
|
+
include MCPClient::Client::TaskApi
|
|
24
|
+
|
|
25
|
+
# Ceiling on the schema-violation text that reaches a log line or an
|
|
26
|
+
# exception (the validator already bounds its error count).
|
|
27
|
+
MAX_VIOLATION_TEXT = 4000
|
|
28
|
+
|
|
10
29
|
# Elicitation modes implemented by this client (MCP 2025-11-25).
|
|
11
30
|
# Requests with a mode outside this set are rejected with -32602.
|
|
12
31
|
SUPPORTED_ELICITATION_MODES = %w[form url].freeze
|
|
13
32
|
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
# These readers are declared one per line with an ordinary doc comment
|
|
34
|
+
# rather than grouped under `@!attribute` directives, because the directive
|
|
35
|
+
# form loses documentation silently: YARD drops the docstring of the LAST
|
|
36
|
+
# directive in a block preceding a combined `attr_reader`, re-registering
|
|
37
|
+
# that name from the statement itself with the leftover (empty) docstring.
|
|
38
|
+
# The `roots` deprecation below was absent from the generated API
|
|
39
|
+
# documentation for exactly that reason, while every check that read the
|
|
40
|
+
# source found it.
|
|
41
|
+
|
|
42
|
+
# @return [Array<MCPClient::ServerBase>] list of servers
|
|
43
|
+
attr_reader :servers
|
|
44
|
+
|
|
45
|
+
# @return [Hash<String, MCPClient::Tool>] cache of tools by composite key (server_id:name)
|
|
46
|
+
attr_reader :tool_cache
|
|
47
|
+
|
|
48
|
+
# @return [Hash<String, MCPClient::Prompt>] cache of prompts by composite key (server_id:name)
|
|
49
|
+
attr_reader :prompt_cache
|
|
50
|
+
|
|
51
|
+
# @return [Hash<String, MCPClient::Resource>] cache of resources by composite key (server_id:uri)
|
|
52
|
+
attr_reader :resource_cache
|
|
53
|
+
|
|
54
|
+
# @return [Logger] logger for client operations
|
|
55
|
+
attr_reader :logger
|
|
56
|
+
|
|
57
|
+
# @return [Array<MCPClient::Root>] list of MCP roots (MCP 2025-06-18)
|
|
58
|
+
# @deprecated Roots is deprecated since MCP 2026-07-28 (SEP-2577); earliest
|
|
59
|
+
# removal is the first revision released on or after 2027-07-28. Reading the
|
|
60
|
+
# list is not itself a first use of the feature — the notice follows
|
|
61
|
+
# configuring a root or serving one — but the list is a deprecated feature's
|
|
62
|
+
# state, and a host reading it is holding one. Pass directories or files
|
|
63
|
+
# through tool parameters, resource URIs or server configuration instead.
|
|
64
|
+
attr_reader :roots
|
|
27
65
|
|
|
28
66
|
# Supported modes for structuredContent validation (MCP 2025-11-25):
|
|
29
67
|
# :warn logs a warning on mismatch, :strict raises a ValidationError.
|
|
30
68
|
STRUCTURED_CONTENT_MODES = %i[warn strict].freeze
|
|
31
69
|
|
|
70
|
+
# Server-config keys whose values carry credentials (HTTP headers, the
|
|
71
|
+
# subprocess environment, inline tokens). Their values are replaced before
|
|
72
|
+
# a config is written to the log.
|
|
73
|
+
SENSITIVE_CONFIG_KEYS = %i[headers env token access_token api_key auth authorization
|
|
74
|
+
password secret client_secret oauth_provider].freeze
|
|
75
|
+
|
|
76
|
+
# Placeholder written in place of a redacted value.
|
|
77
|
+
REDACTED = '[REDACTED]'
|
|
78
|
+
|
|
79
|
+
# Where {#register_notification_handlers} leaves word, on the thread that
|
|
80
|
+
# is routing, that the caches for the notification in hand have already
|
|
81
|
+
# been dropped by the transport's invalidation hook.
|
|
82
|
+
CACHE_INVALIDATION_MARK = :mcp_client_cache_invalidation
|
|
83
|
+
|
|
84
|
+
# Maximum characters of a peer-supplied log message written to the host
|
|
85
|
+
# log. The remote server controls this content, so an unbounded message
|
|
86
|
+
# would let it inflate log storage at will.
|
|
87
|
+
MAX_PEER_LOG_MESSAGE_LENGTH = 4096
|
|
88
|
+
|
|
32
89
|
# Initialize a new MCPClient::Client
|
|
33
90
|
# @param mcp_server_configs [Array<Hash>] configurations for MCP servers
|
|
34
91
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
|
35
92
|
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
|
|
36
|
-
# @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
|
|
37
|
-
#
|
|
93
|
+
# @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18).
|
|
94
|
+
# Deprecated since MCP 2026-07-28 (SEP-2577); earliest removal is the first revision
|
|
95
|
+
# released on or after 2027-07-28. Pass directories or files through tool parameters,
|
|
96
|
+
# resource URIs or server configuration instead.
|
|
97
|
+
# @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25).
|
|
98
|
+
# Deprecated since MCP 2026-07-28 (SEP-2577); earliest removal is the first revision
|
|
99
|
+
# released on or after 2027-07-28. Integrate directly with the LLM provider API instead.
|
|
38
100
|
# @param sampling_supports_tools [Boolean] whether the sampling handler supports tool use
|
|
39
101
|
# (MCP 2025-11-25 / SEP-1577); declares the sampling.tools capability and forwards
|
|
40
102
|
# tools/toolChoice params to the handler instead of rejecting tool-enabled requests
|
|
@@ -44,14 +106,23 @@ module MCPClient
|
|
|
44
106
|
# structuredContent does not match the tool's declared outputSchema (MCP 2025-11-25:
|
|
45
107
|
# "Clients SHOULD validate structured results against this schema"): :warn (default)
|
|
46
108
|
# logs a warning, :strict raises MCPClient::Errors::ValidationError
|
|
109
|
+
# @param request_meta [Hash, #call, nil] metadata merged into every request's `_meta`
|
|
110
|
+
# (a Hash, or a callable returning one, evaluated per request) — e.g. OpenTelemetry
|
|
111
|
+
# trace context (`traceparent`, `tracestate`, `baggage`, MCP 2026-07-28) or
|
|
112
|
+
# vendor-prefixed keys. Reserved protocol keys cannot be set this way.
|
|
113
|
+
# @param extensions [Array<String>, Hash{String => Hash}, nil] MCP 2026-07-28 extensions to declare
|
|
114
|
+
# in every request's clientCapabilities (identifier, or identifier => settings), e.g.
|
|
115
|
+
# `['io.modelcontextprotocol/tasks']` to let servers answer tools/call with a task
|
|
47
116
|
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
|
|
117
|
+
sampling_supports_tools: false, client_info: nil, validate_structured_content: :warn,
|
|
118
|
+
request_meta: nil, extensions: nil)
|
|
49
119
|
unless STRUCTURED_CONTENT_MODES.include?(validate_structured_content)
|
|
50
120
|
raise ArgumentError, "validate_structured_content must be one of #{STRUCTURED_CONTENT_MODES.inspect}, " \
|
|
51
121
|
"got #{validate_structured_content.inspect}"
|
|
52
122
|
end
|
|
53
123
|
|
|
54
124
|
@validate_structured_content = validate_structured_content
|
|
125
|
+
@extensions = normalize_extensions(extensions)
|
|
55
126
|
# Preserve a caller-supplied logger's formatter (only tag progname), and
|
|
56
127
|
# install the default formatter solely on a logger we create ourselves.
|
|
57
128
|
# Overwriting the formatter of an application's logger would silently
|
|
@@ -65,10 +136,32 @@ module MCPClient
|
|
|
65
136
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
|
66
137
|
end
|
|
67
138
|
@servers = mcp_server_configs.map do |config|
|
|
68
|
-
@logger.debug("Creating server with config: #{config.inspect}")
|
|
139
|
+
@logger.debug("Creating server with config: #{redact_config(config).inspect}")
|
|
69
140
|
MCPClient::ServerFactory.create(config, logger: @logger)
|
|
70
141
|
end
|
|
71
142
|
@tool_cache = {}
|
|
143
|
+
# Bumped whenever the tool cache is emptied, so a list_tools that was
|
|
144
|
+
# already in flight can tell that its definitions were superseded while
|
|
145
|
+
# it ran (MCP 2026-07-28: a HeaderMismatch refresh announces itself as a
|
|
146
|
+
# tools/list_changed).
|
|
147
|
+
@tool_cache_generation = 0
|
|
148
|
+
@cache_mutex = Mutex.new
|
|
149
|
+
# The effective-parameter fingerprint each server's slice of a list
|
|
150
|
+
# cache was filled under (MCP 2026-07-28 caching: a result is served
|
|
151
|
+
# only to a request that would carry the same parameters).
|
|
152
|
+
@cache_params = Hash.new { |h, k| h[k] = {}.compare_by_identity }
|
|
153
|
+
# Which servers have filled their slice of a list cache, so a snapshot
|
|
154
|
+
# is known to be complete however few items it holds: a server that
|
|
155
|
+
# legitimately lists nothing must be served from the cache too, not
|
|
156
|
+
# asked again on every call.
|
|
157
|
+
@cache_filled = {}
|
|
158
|
+
# One lock for the list caches and their parameter tags: a freshness
|
|
159
|
+
# check and the copy it approves are one snapshot, and the notification
|
|
160
|
+
# thread's clears wait for it.
|
|
161
|
+
@cache_mutex = Mutex.new
|
|
162
|
+
# Bumped by every write under @cache_mutex, so a freshness verdict
|
|
163
|
+
# reached outside the lock can be revalidated before a copy is served.
|
|
164
|
+
@cache_version = 0
|
|
72
165
|
# Active progressToken -> callback registrations (MCP progress utility)
|
|
73
166
|
@progress_callbacks = {}
|
|
74
167
|
@progress_mutex = Mutex.new
|
|
@@ -78,28 +171,29 @@ module MCPClient
|
|
|
78
171
|
@notification_listeners = []
|
|
79
172
|
# Elicitation handler (MCP 2025-06-18)
|
|
80
173
|
@elicitation_handler = elicitation_handler
|
|
81
|
-
# Sampling handler (MCP 2025-11-25)
|
|
174
|
+
# Sampling handler (MCP 2025-11-25; deprecated in 2026-07-28, SEP-2577)
|
|
82
175
|
@sampling_handler = sampling_handler
|
|
176
|
+
MCPClient::Deprecations.warn(:sampling, @logger) if sampling_handler
|
|
83
177
|
# Whether the sampling handler supports tool use (SEP-1577)
|
|
84
178
|
@sampling_supports_tools = sampling_supports_tools
|
|
85
|
-
# Roots (MCP 2025-06-18)
|
|
179
|
+
# Roots (MCP 2025-06-18; deprecated in 2026-07-28, SEP-2577)
|
|
86
180
|
@roots = normalize_roots(roots)
|
|
181
|
+
MCPClient::Deprecations.warn(:roots, @logger) unless @roots.empty?
|
|
87
182
|
# Register default and user-defined notification handlers on each server
|
|
88
183
|
@servers.each do |server|
|
|
89
|
-
|
|
90
|
-
server
|
|
91
|
-
server.on_notification do |method, params|
|
|
92
|
-
# Default notification processing (e.g., cache invalidation, logging)
|
|
93
|
-
process_notification(server, method, params)
|
|
94
|
-
# Invoke user-defined listeners
|
|
95
|
-
@notification_listeners.each { |cb| cb.call(server, method, params) }
|
|
96
|
-
end
|
|
184
|
+
configure_server_identity(server, client_info, request_meta)
|
|
185
|
+
register_notification_handlers(server)
|
|
97
186
|
# Register feature callbacks only for features the host actually
|
|
98
187
|
# supports: transports derive their declared client capabilities from
|
|
99
188
|
# the callbacks registered before connecting, and MCP forbids using
|
|
100
189
|
# capabilities that were not negotiated.
|
|
190
|
+
# The transports call the callback with (request_id, params) only, so
|
|
191
|
+
# the asking server is closed over here: the URL-mode host contract
|
|
192
|
+
# depends on its protocol era (MCP 2026-07-28 removed elicitationId).
|
|
101
193
|
if @elicitation_handler && server.respond_to?(:on_elicitation_request)
|
|
102
|
-
server.on_elicitation_request
|
|
194
|
+
server.on_elicitation_request do |request_id, request_params|
|
|
195
|
+
handle_elicitation_request(request_id, request_params, server)
|
|
196
|
+
end
|
|
103
197
|
end
|
|
104
198
|
# The client always implements the roots feature (roots/list and
|
|
105
199
|
# list_changed notifications), independent of the current roots list.
|
|
@@ -120,29 +214,14 @@ module MCPClient
|
|
|
120
214
|
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
|
121
215
|
# @raise [MCPClient::Errors::PromptGetError] if no prompts could be retrieved from any server
|
|
122
216
|
def list_prompts(cache: true)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
servers.each do |server|
|
|
129
|
-
server.list_prompts.each do |prompt|
|
|
130
|
-
cache_key = cache_key_for(server, prompt.name)
|
|
131
|
-
@prompt_cache[cache_key] = prompt
|
|
132
|
-
prompts << prompt
|
|
217
|
+
holding_request_meta('prompts/list') do
|
|
218
|
+
if cache && (snapshot = cached_snapshot(:prompts, @prompt_cache))
|
|
219
|
+
release_held_request_meta
|
|
220
|
+
return snapshot
|
|
133
221
|
end
|
|
134
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
135
|
-
# Fast-fail on authorization errors for better user experience
|
|
136
|
-
# If this is the first server or we haven't collected any prompts yet,
|
|
137
|
-
# raise the auth error directly to avoid cascading error messages
|
|
138
|
-
raise e if e.message.include?('Authorization failed') && prompts.empty?
|
|
139
|
-
|
|
140
|
-
# Store the error and try other servers
|
|
141
|
-
connection_errors << e
|
|
142
|
-
@logger.error("Server error: #{e.message}")
|
|
143
|
-
end
|
|
144
222
|
|
|
145
|
-
|
|
223
|
+
collect_prompts_from_servers(cache)
|
|
224
|
+
end
|
|
146
225
|
end
|
|
147
226
|
|
|
148
227
|
# Gets a specific prompt by name with the given parameters
|
|
@@ -200,44 +279,24 @@ module MCPClient
|
|
|
200
279
|
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
|
201
280
|
# @raise [MCPClient::Errors::ResourceReadError] if no resources could be retrieved from any server
|
|
202
281
|
def list_resources(cache: true, cursor: nil)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
# Use cache if available and no cursor
|
|
214
|
-
return { 'resources' => @resource_cache.values, 'nextCursor' => nil } if cache && !@resource_cache.empty?
|
|
215
|
-
|
|
216
|
-
resources = []
|
|
217
|
-
connection_errors = []
|
|
218
|
-
|
|
219
|
-
servers.each do |server|
|
|
220
|
-
result = server.list_resources
|
|
221
|
-
resource_list = result['resources'] || []
|
|
282
|
+
holding_request_meta('resources/list') do
|
|
283
|
+
# If cursor is provided, we can only query one server (the one that provided the cursor)
|
|
284
|
+
# This is a limitation of aggregating multiple servers
|
|
285
|
+
if cursor
|
|
286
|
+
# For now, just use the first server when cursor is provided
|
|
287
|
+
return servers.first.list_resources(cursor: cursor) if servers.any?
|
|
288
|
+
|
|
289
|
+
return { 'resources' => [], 'nextCursor' => nil }
|
|
290
|
+
end
|
|
222
291
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
resources
|
|
292
|
+
# Use cache if available and no cursor
|
|
293
|
+
if cache && (snapshot = cached_snapshot(:resources, @resource_cache))
|
|
294
|
+
release_held_request_meta
|
|
295
|
+
return { 'resources' => snapshot, 'nextCursor' => nil }
|
|
227
296
|
end
|
|
228
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
229
|
-
# Fast-fail on authorization errors for better user experience
|
|
230
|
-
# If this is the first server or we haven't collected any resources yet,
|
|
231
|
-
# raise the auth error directly to avoid cascading error messages
|
|
232
|
-
raise e if e.message.include?('Authorization failed') && resources.empty?
|
|
233
|
-
|
|
234
|
-
# Store the error and try other servers
|
|
235
|
-
connection_errors << e
|
|
236
|
-
@logger.error("Server error: #{e.message}")
|
|
237
|
-
end
|
|
238
297
|
|
|
239
|
-
|
|
240
|
-
|
|
298
|
+
collect_resources_from_servers(cache)
|
|
299
|
+
end
|
|
241
300
|
end
|
|
242
301
|
|
|
243
302
|
# Reads a specific resource by URI
|
|
@@ -263,36 +322,14 @@ module MCPClient
|
|
|
263
322
|
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
|
264
323
|
# @raise [MCPClient::Errors::ToolCallError] if no tools could be retrieved from any server
|
|
265
324
|
def list_tools(cache: true)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
servers.each do |server|
|
|
272
|
-
server.list_tools.each do |tool|
|
|
273
|
-
cache_key = cache_key_for(server, tool.name)
|
|
274
|
-
@tool_cache[cache_key] = tool
|
|
275
|
-
tools << tool
|
|
325
|
+
holding_request_meta('tools/list') do
|
|
326
|
+
if cache && (snapshot = cached_snapshot(:tools, @tool_cache))
|
|
327
|
+
release_held_request_meta
|
|
328
|
+
return snapshot
|
|
276
329
|
end
|
|
277
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
278
|
-
# Fast-fail on authorization errors for better user experience
|
|
279
|
-
# If this is the first server or we haven't collected any tools yet,
|
|
280
|
-
# raise the auth error directly to avoid cascading error messages
|
|
281
|
-
raise e if e.message.include?('Authorization failed') && tools.empty?
|
|
282
|
-
|
|
283
|
-
# Store the error and try other servers
|
|
284
|
-
connection_errors << e
|
|
285
|
-
@logger.error("Server error: #{e.message}")
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# If we didn't get any tools from any server but have servers configured, report failure
|
|
289
|
-
if tools.empty? && !servers.empty?
|
|
290
|
-
raise connection_errors.first if connection_errors.any?
|
|
291
330
|
|
|
292
|
-
|
|
331
|
+
collect_tools_from_servers(cache)
|
|
293
332
|
end
|
|
294
|
-
|
|
295
|
-
tools
|
|
296
333
|
end
|
|
297
334
|
|
|
298
335
|
# Calls a specific tool by name with the given parameters
|
|
@@ -300,6 +337,9 @@ module MCPClient
|
|
|
300
337
|
# @param parameters [Hash] the parameters to pass to the tool
|
|
301
338
|
# @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
|
|
302
339
|
# @return [Object] the result of the tool invocation
|
|
340
|
+
# @raise [MCPClient::Errors::ValidationError] when the parameters miss a
|
|
341
|
+
# required property, or the tool's inputSchema declares a JSON Schema
|
|
342
|
+
# dialect this client does not support (MCP 2026-07-28)
|
|
303
343
|
def call_tool(tool_name, parameters, server: nil, progress: nil)
|
|
304
344
|
tool = resolve_tool(tool_name, server: server)
|
|
305
345
|
|
|
@@ -315,20 +355,51 @@ module MCPClient
|
|
|
315
355
|
# request _meta and route matching notifications/progress to the
|
|
316
356
|
# caller's callback while the request is active.
|
|
317
357
|
parameters, token = setup_progress_tracking(parameters, progress)
|
|
358
|
+
# The session the call is made in: a task it comes back as belongs to
|
|
359
|
+
# that session, not to one that replaced it while the answer was read.
|
|
360
|
+
# The call goes into that very session and no other — a transport that
|
|
361
|
+
# reconnects inside the request would otherwise run the tool in the
|
|
362
|
+
# replacement session while the task is stamped with the sampled one,
|
|
363
|
+
# and the wait would then refuse a task whose (possibly non-idempotent)
|
|
364
|
+
# tool has already run, inviting a duplicate retry.
|
|
365
|
+
task_epoch = tasks_extension? ? invocation_session_epoch(server) : nil
|
|
366
|
+
|
|
367
|
+
# The call and the re-resolve that follows it share one slot for the
|
|
368
|
+
# definition the transport's request goes out under, so a call that a
|
|
369
|
+
# notification listener nests inside this one cannot leave its own
|
|
370
|
+
# there.
|
|
371
|
+
with_called_tool_definition(server) do
|
|
372
|
+
result = begin
|
|
373
|
+
pinned_to_session(server, task_epoch) { server.call_tool(tool_name, parameters) }
|
|
374
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
375
|
+
# Add server identity information to the error for better context
|
|
376
|
+
server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
|
|
377
|
+
raise MCPClient::Errors::ToolCallError,
|
|
378
|
+
"Error calling tool '#{tool_name}': #{e.message} (Server: #{server_id})"
|
|
379
|
+
ensure
|
|
380
|
+
# Tokens are only valid for the lifetime of the request: dropping the
|
|
381
|
+
# registration filters out stale post-completion notifications.
|
|
382
|
+
unregister_progress_callback(token) if token
|
|
383
|
+
end
|
|
318
384
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
|
|
385
|
+
# MCP 2026-07-28 HeaderMismatch recovery re-derives a call's
|
|
386
|
+
# Mcp-Param-* headers from a refreshed tools/list, so the attempt that
|
|
387
|
+
# was answered may have gone out under a definition this client never
|
|
388
|
+
# resolved. Validate against that one -- never against the transport's
|
|
389
|
+
# current list, which a tools/list_changed racing the call may already
|
|
390
|
+
# have replaced with a definition the server never used. It is read
|
|
391
|
+
# here, before a task's result is waited for: a refresh that lands
|
|
392
|
+
# during a wait that may take minutes belongs to another invocation and
|
|
393
|
+
# says nothing about the definition this one was answered under.
|
|
394
|
+
called = called_tool_definition(server, tool_name)
|
|
395
|
+
|
|
396
|
+
# MCP 2026-07-28 tasks extension: the server may have turned the call
|
|
397
|
+
# into a task; drive it to its final result so the contract of this
|
|
398
|
+
# method does not change.
|
|
399
|
+
result = complete_task_result(tool_name, server, result, task_epoch)
|
|
400
|
+
|
|
401
|
+
validate_called_result!(called || tool, result)
|
|
329
402
|
end
|
|
330
|
-
|
|
331
|
-
validate_structured_content!(tool, result)
|
|
332
403
|
end
|
|
333
404
|
|
|
334
405
|
# Convert MCP tools to OpenAI function specifications
|
|
@@ -361,14 +432,38 @@ module MCPClient
|
|
|
361
432
|
# Clean up all server connections
|
|
362
433
|
def cleanup
|
|
363
434
|
servers.each(&:cleanup)
|
|
435
|
+
# The transports forgot their results; the slices built from them go too.
|
|
436
|
+
clear_cache
|
|
437
|
+
clear_task_states
|
|
364
438
|
end
|
|
365
439
|
|
|
366
|
-
#
|
|
440
|
+
# The list kinds this client caches, each with the transport-level cache
|
|
441
|
+
# behind it.
|
|
442
|
+
CACHED_LIST_KINDS = %i[tools prompts resources].freeze
|
|
443
|
+
|
|
444
|
+
# Clear the cached lists so that the next list_tools, list_prompts or
|
|
445
|
+
# list_resources fetches fresh data.
|
|
367
446
|
# @return [void]
|
|
368
447
|
def clear_cache
|
|
369
|
-
|
|
370
|
-
@
|
|
371
|
-
|
|
448
|
+
clear_tool_cache
|
|
449
|
+
@cache_mutex.synchronize do
|
|
450
|
+
@cache_version += 1
|
|
451
|
+
@prompt_cache.clear
|
|
452
|
+
@resource_cache.clear
|
|
453
|
+
# A slice's tag goes with the slice: a leftover tag must not vouch
|
|
454
|
+
# for a server whose slice a later, partial refill never rebuilt.
|
|
455
|
+
@cache_params.clear
|
|
456
|
+
@cache_filled.clear
|
|
457
|
+
end
|
|
458
|
+
# The promise is fresh data, and a transport holding a list the server
|
|
459
|
+
# bounded with a positive `ttlMs` (MCP 2026-07-28
|
|
460
|
+
# server/utilities/caching) would answer the next listing from it
|
|
461
|
+
# without sending anything at all. Dropped outside this client's lock:
|
|
462
|
+
# each transport takes its own.
|
|
463
|
+
servers.each do |server|
|
|
464
|
+
CACHED_LIST_KINDS.each { |kind| refresh_server_cache(server, kind) }
|
|
465
|
+
forget_schema_checks
|
|
466
|
+
end
|
|
372
467
|
end
|
|
373
468
|
|
|
374
469
|
# Register a callback for JSON-RPC notifications from servers
|
|
@@ -378,11 +473,44 @@ module MCPClient
|
|
|
378
473
|
@notification_listeners << block
|
|
379
474
|
end
|
|
380
475
|
|
|
476
|
+
# Register the host's control over a multi round-trip request's
|
|
477
|
+
# out-of-band wait on every server (MCP 2026-07-28 client/elicitation
|
|
478
|
+
# "URL Mode": manual retry/cancel controls). See
|
|
479
|
+
# {MCPClient::JsonRpcCommon#on_input_required_wait} for the contract.
|
|
480
|
+
# @param block [Proc] callback that receives an InputRequiredWait
|
|
481
|
+
# @return [void]
|
|
482
|
+
def on_input_required_wait(&block)
|
|
483
|
+
@input_required_wait_handler = block
|
|
484
|
+
@servers.each do |server|
|
|
485
|
+
server.on_input_required_wait(&block) if server.respond_to?(:on_input_required_wait)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Resume a multi round-trip request from the continuation an
|
|
490
|
+
# {MCPClient::Errors::InputRequiredError} carries, on the transport that
|
|
491
|
+
# raised it. The result is the transport's, as {#call_tool} would have
|
|
492
|
+
# returned it before validation.
|
|
493
|
+
# @param error [MCPClient::Errors::InputRequiredError] a resumable error
|
|
494
|
+
# @param timeout [Numeric, nil] per-request timeout for the resumed request
|
|
495
|
+
# @return [Object] the final (complete) result
|
|
496
|
+
# @raise [ArgumentError] if the error carries no continuation or names no transport
|
|
497
|
+
def resume_input_required(error, timeout: nil)
|
|
498
|
+
transport = error.respond_to?(:transport) ? error.transport : nil
|
|
499
|
+
raise ArgumentError, 'the error names no transport to resume on' unless transport
|
|
500
|
+
|
|
501
|
+
transport.resume_input_required(error, timeout: timeout)
|
|
502
|
+
end
|
|
503
|
+
|
|
381
504
|
# Set the roots for this client (MCP 2025-06-18)
|
|
382
505
|
# When roots are changed, a notification is sent to all connected servers
|
|
506
|
+
# @deprecated Roots are deprecated since MCP 2026-07-28 (SEP-2577);
|
|
507
|
+
# earliest removal is the first revision released on or after
|
|
508
|
+
# 2027-07-28. Pass directories or files through tool parameters,
|
|
509
|
+
# resource URIs or server configuration instead.
|
|
383
510
|
# @param new_roots [Array<MCPClient::Root, Hash>] the new roots to set
|
|
384
511
|
# @return [void]
|
|
385
512
|
def roots=(new_roots)
|
|
513
|
+
MCPClient::Deprecations.warn(:roots, @logger)
|
|
386
514
|
@roots = normalize_roots(new_roots)
|
|
387
515
|
# Notify servers that roots have changed
|
|
388
516
|
notify_roots_changed
|
|
@@ -443,8 +571,20 @@ module MCPClient
|
|
|
443
571
|
raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
|
|
444
572
|
|
|
445
573
|
begin
|
|
446
|
-
|
|
447
|
-
|
|
574
|
+
task_epoch = tasks_extension? ? invocation_session_epoch(server) : nil
|
|
575
|
+
# Use the streaming API if it's available, opened in the session the
|
|
576
|
+
# epoch was sampled for: a chunk that comes back as a task is stamped
|
|
577
|
+
# with that session, so the call must not have been written into the
|
|
578
|
+
# one that replaced it (see #call_tool). The stream every built-in
|
|
579
|
+
# transport hands back is lazy, so the call itself goes out under the
|
|
580
|
+
# pin the enumeration takes (see #streamed_call_chunks) — this one
|
|
581
|
+
# covers a transport that sends while building it.
|
|
582
|
+
stream = pinned_to_session(server, task_epoch) { server.call_tool_streaming(tool_name, parameters) }
|
|
583
|
+
# Every stream goes through the wrapper, whether or not tasks are in
|
|
584
|
+
# play: "Clients SHOULD validate structured results against this
|
|
585
|
+
# schema" is about a result, not about the method that fetched it, and
|
|
586
|
+
# so is the dialect a result's schema declares.
|
|
587
|
+
streamed_call_chunks(stream, tool, tool_name, server, epoch: task_epoch)
|
|
448
588
|
rescue MCPClient::Errors::ConnectionError => e
|
|
449
589
|
# Add server identity information to the error for better context
|
|
450
590
|
server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
|
|
@@ -512,162 +652,57 @@ module MCPClient
|
|
|
512
652
|
srv.complete(ref: ref, argument: argument, context: context)
|
|
513
653
|
end
|
|
514
654
|
|
|
515
|
-
#
|
|
516
|
-
#
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
#
|
|
521
|
-
#
|
|
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
|
|
555
|
-
|
|
556
|
-
begin
|
|
557
|
-
result = srv.rpc_request('tools/call', rpc_params)
|
|
558
|
-
MCPClient::Task.from_create_result(result, server: srv)
|
|
559
|
-
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
560
|
-
raise MCPClient::Errors::TaskError, "Error creating task for tool '#{tool_name}': #{e.message}"
|
|
561
|
-
end
|
|
562
|
-
end
|
|
563
|
-
|
|
564
|
-
# Get the current state of a task (tasks/get, MCP 2025-11-25)
|
|
565
|
-
# @param task_id [String] the ID of the task to query
|
|
566
|
-
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
567
|
-
# @return [MCPClient::Task] the task with current status
|
|
568
|
-
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
569
|
-
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
570
|
-
# @raise [MCPClient::Errors::TaskError] if retrieving the task fails
|
|
571
|
-
def get_task(task_id, server: nil)
|
|
572
|
-
srv = select_server(server)
|
|
573
|
-
|
|
574
|
-
begin
|
|
575
|
-
result = srv.rpc_request('tasks/get', { taskId: task_id })
|
|
576
|
-
MCPClient::Task.from_json(result, server: srv)
|
|
577
|
-
rescue MCPClient::Errors::ServerError => e
|
|
578
|
-
raise task_error_from(e, task_id, 'getting')
|
|
579
|
-
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
580
|
-
raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
|
|
581
|
-
end
|
|
582
|
-
end
|
|
583
|
-
|
|
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
|
|
655
|
+
# Open a long-lived notification stream on a server (MCP 2026-07-28
|
|
656
|
+
# subscriptions/listen). The subscription's notifications also flow
|
|
657
|
+
# through the client's regular notification handling (cache
|
|
658
|
+
# invalidation, on_notification listeners).
|
|
659
|
+
# @param notifications [Hash] the SubscriptionFilter: tools_list_changed,
|
|
660
|
+
# prompts_list_changed, resources_list_changed (booleans),
|
|
661
|
+
# resource_subscriptions, task_ids (arrays of strings)
|
|
595
662
|
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
596
|
-
# @
|
|
597
|
-
#
|
|
598
|
-
#
|
|
599
|
-
|
|
663
|
+
# @param ack_timeout [Numeric, false, nil] seconds to wait for the server's
|
|
664
|
+
# acknowledgment before giving the listen up and cancelling it; nil takes
|
|
665
|
+
# the transport's own read timeout, false waits for ever
|
|
666
|
+
# @yield [method, params] notifications delivered on the subscription
|
|
667
|
+
# @return [MCPClient::Subscription]
|
|
668
|
+
# @raise [MCPClient::Errors::CapabilityError] if the server is not a 2026-07-28 server
|
|
669
|
+
def listen(notifications:, server: nil, ack_timeout: nil, &listener)
|
|
600
670
|
srv = select_server(server)
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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)
|
|
632
|
-
# @param task_id [String] the ID of the task to cancel
|
|
633
|
-
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
634
|
-
# @return [MCPClient::Task] the task with updated (cancelled) status
|
|
635
|
-
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
636
|
-
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
637
|
-
# @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task)
|
|
638
|
-
def cancel_task(task_id, server: nil)
|
|
639
|
-
srv = select_server(server)
|
|
640
|
-
ensure_task_capability!(srv, 'cancel')
|
|
641
|
-
|
|
642
|
-
begin
|
|
643
|
-
result = srv.rpc_request('tasks/cancel', { taskId: task_id })
|
|
644
|
-
MCPClient::Task.from_json(result, server: srv)
|
|
645
|
-
rescue MCPClient::Errors::ServerError => e
|
|
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}"
|
|
671
|
+
filter = MCPClient::Subscription.normalize_filter(notifications)
|
|
672
|
+
if filter.key?('taskIds')
|
|
673
|
+
unless tasks_extension?
|
|
674
|
+
raise MCPClient::Errors::CapabilityError,
|
|
675
|
+
'Task notifications (taskIds) require the tasks extension: pass ' \
|
|
676
|
+
"extensions: ['#{MCPClient::JsonRpcCommon::TASKS_EXTENSION}'] to MCPClient::Client.new"
|
|
650
677
|
end
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
|
|
678
|
+
# The server must have negotiated the extension too (it answers a
|
|
679
|
+
# taskIds filter from a non-declaring client with -32021).
|
|
680
|
+
ensure_task_capability!(srv, 'listen')
|
|
655
681
|
end
|
|
682
|
+
|
|
683
|
+
srv.listen(notifications: notifications, ack_timeout: ack_timeout, &listener)
|
|
656
684
|
end
|
|
657
685
|
|
|
658
686
|
# Set the logging level on all connected servers (MCP 2025-06-18)
|
|
659
687
|
# To set on a specific server, use: client.find_server('name').log_level = 'debug'
|
|
688
|
+
# @deprecated Logging is deprecated since MCP 2026-07-28 (SEP-2577);
|
|
689
|
+
# earliest removal is the first revision released on or after
|
|
690
|
+
# 2027-07-28. Have the server log to stderr (stdio) or use
|
|
691
|
+
# OpenTelemetry instead.
|
|
660
692
|
# @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
|
|
661
693
|
# 'critical', 'alert', 'emergency')
|
|
662
694
|
# @return [Array<Hash>] results from servers
|
|
663
695
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
664
696
|
def log_level=(level)
|
|
697
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
665
698
|
@servers.filter_map do |srv|
|
|
666
699
|
# MCP lifecycle: only use capabilities that were successfully
|
|
667
700
|
# negotiated — skip servers whose NEGOTIATED set lacks logging.
|
|
668
701
|
# Unconnected servers proceed: the transport-level gate re-checks
|
|
669
|
-
# after its handshake establishes the capability set.
|
|
670
|
-
|
|
702
|
+
# after its handshake establishes the capability set. A 2026-07-28
|
|
703
|
+
# server needs no capability at all: the level is a per-request
|
|
704
|
+
# field of every request's _meta, not a logging/setLevel call.
|
|
705
|
+
unless !capabilities_known?(srv) || srv.capability?('logging') || modern_server?(srv)
|
|
671
706
|
@logger.debug("Skipping logging/setLevel for #{srv.name || srv.class.name}: " \
|
|
672
707
|
'logging capability not negotiated')
|
|
673
708
|
next
|
|
@@ -679,85 +714,129 @@ module MCPClient
|
|
|
679
714
|
|
|
680
715
|
private
|
|
681
716
|
|
|
682
|
-
#
|
|
683
|
-
#
|
|
684
|
-
#
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
#
|
|
690
|
-
#
|
|
691
|
-
#
|
|
692
|
-
#
|
|
693
|
-
#
|
|
694
|
-
#
|
|
695
|
-
#
|
|
696
|
-
# @param
|
|
697
|
-
# @param
|
|
698
|
-
# @return [
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
#
|
|
706
|
-
#
|
|
717
|
+
# The chunks of a streaming tools/call, still in the session the call was
|
|
718
|
+
# sampled for.
|
|
719
|
+
#
|
|
720
|
+
# Every built-in transport answers call_tool_streaming with a lazy
|
|
721
|
+
# Enumerator: nothing is sent until the host enumerates it, and the pin
|
|
722
|
+
# around the construction is long gone by then (pins are per thread, and
|
|
723
|
+
# the consumer may not even be the thread that opened the stream). The
|
|
724
|
+
# call is therefore made under a pin taken inside the enumeration itself,
|
|
725
|
+
# so a session that ended meanwhile stops the call at the wire instead of
|
|
726
|
+
# running a (possibly non-idempotent) tool in the session that replaced
|
|
727
|
+
# the sampled one — where the task it answers with would be stamped with
|
|
728
|
+
# a session it never belonged to, and the wait would refuse the very task
|
|
729
|
+
# that ran.
|
|
730
|
+
# @param stream [Enumerator] the transport's lazy stream
|
|
731
|
+
# @param tool [MCPClient::Tool] the definition the call was validated against
|
|
732
|
+
# @param epoch [Integer, nil] the session the call belongs to
|
|
733
|
+
# @return [Enumerator]
|
|
734
|
+
def streamed_call_chunks(stream, tool, tool_name, server, epoch:)
|
|
735
|
+
resolve = tasks_extension? && modern_server?(server)
|
|
736
|
+
Enumerator.new do |yielder|
|
|
737
|
+
pinned_to_session(server, epoch) do
|
|
738
|
+
# The call and the re-resolve that follows it share one slot for the
|
|
739
|
+
# definition the request went out under (see #call_tool). The call
|
|
740
|
+
# happens inside this enumeration, so the slot is opened here:
|
|
741
|
+
# without it the transport's record dies with its own call_tool and
|
|
742
|
+
# the re-resolve would list again, validating the result against a
|
|
743
|
+
# definition newer than the one the call carried.
|
|
744
|
+
with_called_tool_definition(server) do
|
|
745
|
+
called = nil
|
|
746
|
+
read_called = false
|
|
747
|
+
stream.each do |chunk|
|
|
748
|
+
# MCP 2026-07-28 tasks extension: a chunk may be a task; resolve
|
|
749
|
+
# it to the call's result, validated as #call_tool does — against
|
|
750
|
+
# the definition a mid-stream refresh (HeaderMismatch recovery)
|
|
751
|
+
# may have replaced.
|
|
752
|
+
task = resolve && task_result?(chunk)
|
|
753
|
+
# A chunk that is neither a task nor a complete CallToolResult is
|
|
754
|
+
# progress, not an answer: only a result is checked against the
|
|
755
|
+
# tool's outputSchema.
|
|
756
|
+
next yielder << chunk unless task || complete_call_result?(chunk)
|
|
757
|
+
|
|
758
|
+
# The definition the stream's one request went out under, read
|
|
759
|
+
# before a task is waited for (see #call_tool) and read once:
|
|
760
|
+
# every chunk belongs to that same request, and the record
|
|
761
|
+
# describes it rather than whatever the list holds later.
|
|
762
|
+
unless read_called
|
|
763
|
+
called = called_tool_definition(server, tool_name)
|
|
764
|
+
read_called = true
|
|
765
|
+
end
|
|
766
|
+
result = task ? complete_task_result(tool_name, server, chunk, epoch) : chunk
|
|
767
|
+
yielder << validate_called_result!(called || tool, result)
|
|
768
|
+
end
|
|
769
|
+
end
|
|
707
770
|
end
|
|
708
771
|
end
|
|
772
|
+
end
|
|
709
773
|
|
|
710
|
-
|
|
774
|
+
# Whether a streamed chunk is the call's answer rather than an update on
|
|
775
|
+
# its way. MCP 2026-07-28 makes `resultType` required and has clients
|
|
776
|
+
# treat an absent one as "complete" — a rule for *results*, which is
|
|
777
|
+
# what every pre-2026 server sends. A chunk that carries no `resultType`
|
|
778
|
+
# and is shaped as no CallToolResult (a progress object, say) is an
|
|
779
|
+
# update on the way, not an answer to check against an output schema.
|
|
780
|
+
# @param chunk [Object] one chunk of a streaming tools/call
|
|
781
|
+
# @return [Boolean]
|
|
782
|
+
def complete_call_result?(chunk)
|
|
783
|
+
return false unless chunk.is_a?(Hash) && MCPClient::JsonRpcCommon.result_type(chunk) == 'complete'
|
|
711
784
|
|
|
712
|
-
|
|
713
|
-
|
|
785
|
+
# The members a CallToolResult is made of; a chunk carrying none of
|
|
786
|
+
# them (and no `resultType`) is not a result at all.
|
|
787
|
+
chunk.key?('resultType') || chunk.key?(:resultType) ||
|
|
788
|
+
%w[content structuredContent isError].any? { |member| chunk.key?(member) || chunk.key?(member.to_sym) }
|
|
714
789
|
end
|
|
715
790
|
|
|
716
|
-
#
|
|
717
|
-
# @param server [MCPClient::ServerBase] the
|
|
718
|
-
# @param
|
|
719
|
-
# @param
|
|
791
|
+
# Hand the host's identity and request metadata to a transport.
|
|
792
|
+
# @param server [MCPClient::ServerBase] the transport
|
|
793
|
+
# @param client_info [Hash, nil] Implementation info sent as clientInfo
|
|
794
|
+
# @param request_meta [Hash, #call, nil] metadata merged into every request's _meta
|
|
720
795
|
# @return [void]
|
|
721
|
-
def
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
when
|
|
736
|
-
|
|
737
|
-
|
|
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)
|
|
796
|
+
def configure_server_identity(server, client_info, request_meta)
|
|
797
|
+
# Host-provided Implementation info for clientInfo (initialize on
|
|
798
|
+
# legacy servers, per-request _meta on modern ones)
|
|
799
|
+
server.client_info = client_info if client_info && server.respond_to?(:client_info=)
|
|
800
|
+
server.request_meta = request_meta if request_meta && server.respond_to?(:request_meta=)
|
|
801
|
+
return if @extensions.empty? || !server.respond_to?(:declare_extension)
|
|
802
|
+
|
|
803
|
+
@extensions.each { |identifier, settings| server.declare_extension(identifier, settings) }
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# @param extensions [Array, Hash, nil] the extensions option
|
|
807
|
+
# @return [Hash{String => Hash}] identifier => settings
|
|
808
|
+
def normalize_extensions(extensions)
|
|
809
|
+
case extensions
|
|
810
|
+
when nil then {}
|
|
811
|
+
when Hash then extensions.to_h { |identifier, settings| [identifier.to_s, settings || {}] }
|
|
812
|
+
when Array then extensions.to_h { |identifier| [identifier.to_s, {}] }
|
|
751
813
|
else
|
|
752
|
-
|
|
753
|
-
|
|
814
|
+
raise ArgumentError, 'extensions must be an Array of identifiers or a Hash of identifier => settings, ' \
|
|
815
|
+
"got #{extensions.class}"
|
|
754
816
|
end
|
|
755
817
|
end
|
|
756
818
|
|
|
757
|
-
#
|
|
758
|
-
# @
|
|
759
|
-
|
|
760
|
-
|
|
819
|
+
# @param srv [MCPClient::ServerBase]
|
|
820
|
+
# @return [Boolean] whether the server negotiated an MCP 2026-07-28 revision
|
|
821
|
+
def modern_server?(srv)
|
|
822
|
+
srv.respond_to?(:modern?) && srv.modern?
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Whether the server's negotiated capability set is available yet.
|
|
826
|
+
# @param srv [MCPClient::ServerBase] the server
|
|
827
|
+
# @return [Boolean]
|
|
828
|
+
def capabilities_known?(srv)
|
|
829
|
+
srv.respond_to?(:capabilities) && !srv.capabilities.nil?
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# @param mark [Array, nil] what the invalidation hook left behind
|
|
833
|
+
# @param server [MCPClient::ServerBase] the transport routing now
|
|
834
|
+
# @param method [String] the notification being routed
|
|
835
|
+
# @return [Boolean] whether the mark is this notification's
|
|
836
|
+
def mark_covers?(mark, server, method)
|
|
837
|
+
mark.is_a?(Array) && mark[0].equal?(server) && mark[1] == method
|
|
838
|
+
end
|
|
839
|
+
|
|
761
840
|
# Route a notifications/progress message to the callback registered for
|
|
762
841
|
# its progressToken; unknown or stale tokens are debug-logged and dropped
|
|
763
842
|
# (MCP: "Senders and receivers SHOULD track active progress tokens").
|
|
@@ -818,14 +897,20 @@ module MCPClient
|
|
|
818
897
|
@progress_mutex.synchronize { @progress_callbacks.delete(token) }
|
|
819
898
|
end
|
|
820
899
|
|
|
900
|
+
# Handle logging message notification from server (MCP 2025-06-18)
|
|
901
|
+
# @param server_id [String] server identifier for log prefix
|
|
902
|
+
# @param params [Hash] log message params (level, logger, data)
|
|
903
|
+
# @return [void]
|
|
821
904
|
def handle_log_message(server_id, params)
|
|
905
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
822
906
|
level = params['level'] || 'info'
|
|
823
907
|
logger_name = params['logger']
|
|
824
908
|
data = params['data']
|
|
825
909
|
|
|
826
|
-
# Format the message
|
|
827
|
-
|
|
828
|
-
|
|
910
|
+
# Format the message. Both the logger name and the payload come from the
|
|
911
|
+
# remote server, so both are sanitized before they reach the host log.
|
|
912
|
+
prefix = logger_name ? "[#{server_id}:#{sanitize_peer_log_text(logger_name.to_s)}]" : "[#{server_id}]"
|
|
913
|
+
message = sanitize_peer_log_text(data.is_a?(String) ? data : data.inspect)
|
|
829
914
|
|
|
830
915
|
# Map MCP log levels to Ruby Logger levels
|
|
831
916
|
case level.to_s.downcase
|
|
@@ -838,7 +923,38 @@ module MCPClient
|
|
|
838
923
|
when 'error', 'critical', 'alert', 'emergency'
|
|
839
924
|
logger.error("#{prefix} #{message}")
|
|
840
925
|
else
|
|
841
|
-
|
|
926
|
+
# An out-of-enum level is peer-controlled text like any other: it must
|
|
927
|
+
# be sanitized and capped, or it becomes the log-forging vector the
|
|
928
|
+
# sanitizing of `data` was added to close.
|
|
929
|
+
logger.info("#{prefix} [#{sanitize_peer_log_text(level.to_s)}] #{message}")
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# Make peer-supplied log text safe to write to the host log: control
|
|
934
|
+
# characters (notably newlines, which would let a server forge additional
|
|
935
|
+
# log entries) are escaped, and the result is capped.
|
|
936
|
+
# @param text [String] the peer-supplied text
|
|
937
|
+
# @return [String] sanitized, length-bounded text
|
|
938
|
+
def sanitize_peer_log_text(text)
|
|
939
|
+
escaped = text.gsub(/[\x00-\x1F\x7F]/) { |c| format('\\x%02X', c.ord) }
|
|
940
|
+
return escaped if escaped.length <= MAX_PEER_LOG_MESSAGE_LENGTH
|
|
941
|
+
|
|
942
|
+
"#{escaped[0, MAX_PEER_LOG_MESSAGE_LENGTH]}... (truncated from #{escaped.length} chars)"
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# Copy of a server config with credential-bearing values replaced, for
|
|
946
|
+
# safe logging. Nested hashes (headers, env) have every value redacted;
|
|
947
|
+
# sensitive scalars are replaced outright.
|
|
948
|
+
# @param config [Hash, Object] a server configuration
|
|
949
|
+
# @return [Hash, Object] a redacted copy (non-Hash input is returned as-is)
|
|
950
|
+
def redact_config(config)
|
|
951
|
+
return config unless config.is_a?(Hash)
|
|
952
|
+
|
|
953
|
+
config.to_h do |key, value|
|
|
954
|
+
next [key, value] unless SENSITIVE_CONFIG_KEYS.include?(key.to_s.downcase.to_sym)
|
|
955
|
+
|
|
956
|
+
redacted = value.is_a?(Hash) ? value.transform_values { REDACTED } : REDACTED
|
|
957
|
+
[key, redacted]
|
|
842
958
|
end
|
|
843
959
|
end
|
|
844
960
|
|
|
@@ -874,26 +990,42 @@ module MCPClient
|
|
|
874
990
|
end
|
|
875
991
|
end
|
|
876
992
|
|
|
877
|
-
# Validate parameters against tool JSON schema (checks required
|
|
993
|
+
# Validate parameters against tool JSON schema (checks required
|
|
994
|
+
# properties, through the root's `$ref` chain and `allOf` members). A
|
|
995
|
+
# schema declaring a dialect this client does not implement is refused
|
|
996
|
+
# outright, so the call is never sent under a schema nothing could read.
|
|
878
997
|
# @param tool [MCPClient::Tool] tool definition with schema
|
|
879
998
|
# @param parameters [Hash] parameters to validate
|
|
880
|
-
# @raise [MCPClient::Errors::ValidationError] when required params are
|
|
999
|
+
# @raise [MCPClient::Errors::ValidationError] when required params are
|
|
1000
|
+
# missing, or the schema's dialect is not supported
|
|
881
1001
|
def validate_params!(tool, parameters)
|
|
882
1002
|
schema = tool.schema
|
|
1003
|
+
state = input_schema_state(tool)
|
|
1004
|
+
reject_unsupported_dialect!(tool, state, 'input')
|
|
1005
|
+
# An output schema nothing here could read is refused before the call
|
|
1006
|
+
# too: the dialect error is due whatever the tool would answer, and a
|
|
1007
|
+
# (possibly destructive) tool is not run for a result that cannot be
|
|
1008
|
+
# checked.
|
|
1009
|
+
reject_unsupported_dialect!(tool, output_schema_state(tool), 'output')
|
|
1010
|
+
# An input schema the validator cannot interpret asserts nothing: the
|
|
1011
|
+
# call goes out and the server judges its arguments.
|
|
1012
|
+
return if state[:unusable]
|
|
883
1013
|
return unless schema.is_a?(Hash)
|
|
884
1014
|
|
|
885
|
-
|
|
886
|
-
|
|
1015
|
+
# What the schema requires through every applicator that applies
|
|
1016
|
+
# unconditionally: the root, its `$ref` chain, its `allOf` members (the
|
|
1017
|
+
# tools spec: clients SHOULD follow `$ref` resolution when validating
|
|
1018
|
+
# tool inputs). A conditional branch is the server's to judge.
|
|
1019
|
+
required, properties = MCPClient::SchemaValidator.input_requirements(schema)
|
|
1020
|
+
return if required.empty?
|
|
887
1021
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
missing = required.map(&:to_s) - parameters.keys.map(&:to_s)
|
|
1022
|
+
missing = required - parameters.keys.map(&:to_s)
|
|
891
1023
|
|
|
892
1024
|
# Exclude required params that have a default value in the schema,
|
|
893
1025
|
# since the server will apply the default.
|
|
894
1026
|
missing = missing.reject do |param|
|
|
895
|
-
prop = properties[param]
|
|
896
|
-
prop.is_a?(Hash) &&
|
|
1027
|
+
prop = properties[param]
|
|
1028
|
+
prop.is_a?(Hash) && prop.key?('default')
|
|
897
1029
|
end
|
|
898
1030
|
|
|
899
1031
|
return unless missing.any?
|
|
@@ -901,12 +1033,19 @@ module MCPClient
|
|
|
901
1033
|
raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
|
|
902
1034
|
end
|
|
903
1035
|
|
|
1036
|
+
# @param result [Hash] a tool result
|
|
1037
|
+
# @return [Symbol, String, nil] the key its structuredContent sits under
|
|
1038
|
+
def structured_content_key(result)
|
|
1039
|
+
[:structuredContent, 'structuredContent'].find { |k| result.key?(k) }
|
|
1040
|
+
end
|
|
1041
|
+
|
|
904
1042
|
# Validate a tools/call result's structuredContent against the tool's
|
|
905
1043
|
# declared outputSchema (MCP 2025-11-25 server/tools spec: "Clients SHOULD
|
|
906
1044
|
# validate structured results against this schema"; a tool declaring an
|
|
907
1045
|
# outputSchema must return structuredContent in successful results). Error
|
|
908
|
-
# results (isError: true)
|
|
909
|
-
#
|
|
1046
|
+
# results (isError: true) and unfinished ones (resultType
|
|
1047
|
+
# "input_required") are exempt: the conformance requirements apply to
|
|
1048
|
+
# successful, finished results only. Validation covers the common JSON Schema
|
|
910
1049
|
# keywords; the full 2020-12 vocabulary is out of scope (see
|
|
911
1050
|
# MCPClient::SchemaValidator), and when the schema uses keywords outside
|
|
912
1051
|
# that subset a partial-coverage warning is logged in both modes so :strict
|
|
@@ -920,42 +1059,222 @@ module MCPClient
|
|
|
920
1059
|
# is missing from a successful result or does not match the schema
|
|
921
1060
|
def validate_structured_content!(tool, result)
|
|
922
1061
|
return result unless tool.structured_output? && result.is_a?(Hash)
|
|
923
|
-
return result if result['isError'] || result[:isError]
|
|
924
1062
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1063
|
+
# A dialect this client cannot read is an error for every result, an
|
|
1064
|
+
# error result included (the MUST is not limited to successful ones).
|
|
1065
|
+
reject_unsupported_dialect!(tool, output_schema_state(tool), 'output')
|
|
1066
|
+
# An error result may carry no structuredContent at all; one that does
|
|
1067
|
+
# is bound by the output schema like any other (the tools specification
|
|
1068
|
+
# exempts nothing about error results), so what is there is checked.
|
|
1069
|
+
return result if (result['isError'] || result[:isError]) && !structured_content_key(result)
|
|
1070
|
+
# An unfinished result (MCP 2026-07-28 resultType "input_required") is
|
|
1071
|
+
# not a successful one either: it carries the continuation instead of
|
|
1072
|
+
# the tool's output. Checking it for structuredContent would fail the
|
|
1073
|
+
# call on a conformance rule that does not apply yet, and would throw
|
|
1074
|
+
# the continuation away with it.
|
|
1075
|
+
return result unless MCPClient::JsonRpcCommon.result_type(result) == 'complete'
|
|
1076
|
+
|
|
1077
|
+
unsupported = warn_partial_schema_coverage(tool)
|
|
1078
|
+
reject_partial_schema_coverage!(tool, unsupported)
|
|
1079
|
+
|
|
1080
|
+
# MCP 2026-07-28: structuredContent "can be any JSON value (object,
|
|
1081
|
+
# array, string, number, boolean, or null)", so presence is decided by
|
|
1082
|
+
# the key, not by the value. MCP 2025-11-25 types it as an object, so on
|
|
1083
|
+
# a session negotiated to that revision anything else — a null, an
|
|
1084
|
+
# array, a string, a number, a boolean — is what it was there: no
|
|
1085
|
+
# structured content at all. The widening is a 2026-07-28 rule and does
|
|
1086
|
+
# not reach back over a legacy session.
|
|
1087
|
+
key = structured_content_key(result)
|
|
1088
|
+
key = nil if key && !result[key].is_a?(Hash) && legacy_server?(tool.server)
|
|
1089
|
+
# Dropping the non-object leaves an error result what it was: one
|
|
1090
|
+
# carrying no structured content, which it is allowed to be. Reporting
|
|
1091
|
+
# it as a successful result missing its output would refuse — in
|
|
1092
|
+
# :strict, raise on — a result the tools specification permits.
|
|
1093
|
+
return result if key.nil? && (result['isError'] || result[:isError])
|
|
1094
|
+
|
|
1095
|
+
unless key
|
|
929
1096
|
handle_structured_content_violation(
|
|
930
|
-
"Tool '#{tool.name}' declares an output schema but its successful result
|
|
931
|
-
'(required by the MCP
|
|
1097
|
+
"Tool '#{sanitize_peer_log_text(tool.name.to_s)}' declares an output schema but its successful result " \
|
|
1098
|
+
'carries no structuredContent (required by the MCP tools spec)'
|
|
932
1099
|
)
|
|
933
1100
|
return result
|
|
934
1101
|
end
|
|
935
1102
|
|
|
936
|
-
|
|
1103
|
+
# An unusable output schema (unsupported dialect, external $ref, out of
|
|
1104
|
+
# bounds) is a violation too, never a permissive pass.
|
|
1105
|
+
errors = MCPClient::SchemaValidator.validate(result[key], tool.output_schema)
|
|
937
1106
|
unless errors.empty?
|
|
1107
|
+
# Schema and data text is peer-controlled: it is sanitized and
|
|
1108
|
+
# bounded before it reaches a log line or an exception.
|
|
938
1109
|
handle_structured_content_violation(
|
|
939
|
-
"Structured content for tool '#{tool.name}' does not match its output
|
|
1110
|
+
"Structured content for tool '#{sanitize_peer_log_text(tool.name.to_s)}' does not match its output " \
|
|
1111
|
+
"schema: #{sanitize_peer_log_text(errors.join('; '))[0, MAX_VIOLATION_TEXT]}"
|
|
940
1112
|
)
|
|
941
1113
|
end
|
|
942
1114
|
result
|
|
943
1115
|
end
|
|
944
1116
|
|
|
1117
|
+
# What the preflight made of a tool's inputSchema, checked once per tool
|
|
1118
|
+
# definition. A schema the validator cannot use is warned about (MCP
|
|
1119
|
+
# 2026-07-28: an unsupported dialect, a network `$ref` that is never
|
|
1120
|
+
# dereferenced, or a schema beyond the resource bounds); for everything
|
|
1121
|
+
# but an unsupported dialect the call still goes out — the server owns
|
|
1122
|
+
# argument validation — and the host learns that local parameter checks
|
|
1123
|
+
# are incomplete.
|
|
1124
|
+
# @param tool [MCPClient::Tool]
|
|
1125
|
+
# @return [Hash] :unusable and the :dialect that is not supported, if any
|
|
1126
|
+
def input_schema_state(tool)
|
|
1127
|
+
return {} if tool.schema.nil?
|
|
1128
|
+
|
|
1129
|
+
# Keyed by the definition's identity as well, so a refreshed tool
|
|
1130
|
+
# definition (list_changed, cache expiry, HeaderMismatch recovery) is
|
|
1131
|
+
# re-checked while the copies the client cache hands out are not. The
|
|
1132
|
+
# identity, not the schema's hash: hashing a peer-supplied document
|
|
1133
|
+
# walks it whole (or overflows the stack) before the bounded check
|
|
1134
|
+
# could reject it.
|
|
1135
|
+
@input_schema_warnings ||= {}
|
|
1136
|
+
key = [tool.server&.object_id, tool.name]
|
|
1137
|
+
known = @input_schema_warnings[key]
|
|
1138
|
+
return known if known && known[:identity].equal?(tool_definition_identity(tool))
|
|
1139
|
+
|
|
1140
|
+
preflight = {}
|
|
1141
|
+
problems = MCPClient::SchemaValidator.check_schema(tool.schema, preflight)
|
|
1142
|
+
state = { identity: tool_definition_identity(tool), unusable: !problems.empty?,
|
|
1143
|
+
dialect: preflight[:unsupported_dialect] }
|
|
1144
|
+
@input_schema_warnings[key] = state
|
|
1145
|
+
warn_unusable_input_schema(tool, problems)
|
|
1146
|
+
state
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# @param problems [Array<String>] why the input schema is unusable
|
|
1150
|
+
# @return [void]
|
|
1151
|
+
def warn_unusable_input_schema(tool, problems)
|
|
1152
|
+
return if problems.empty?
|
|
1153
|
+
|
|
1154
|
+
@logger.warn("Tool '#{sanitize_peer_log_text(tool.name.to_s)}' input schema is not usable for validation: " \
|
|
1155
|
+
"#{sanitize_peer_log_text(problems.join('; '))}")
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
# MCP 2026-07-28 basic "Implementation Requirements": a client "MUST
|
|
1159
|
+
# handle unsupported dialects gracefully by returning an appropriate
|
|
1160
|
+
# error indicating the dialect is not supported". A dialect this client
|
|
1161
|
+
# does not implement is not a schema it may quietly skip — it cannot
|
|
1162
|
+
# know what the arguments must look like, and the caller must be able to
|
|
1163
|
+
# see that — so the call is refused before it is sent. SEP-2106 assigns
|
|
1164
|
+
# no JSON-RPC code to this, so it is a library error, not a wire one.
|
|
1165
|
+
# The requirement is not conditional on the structured-content mode: a
|
|
1166
|
+
# dialect the client cannot read is not a result it may choose to only
|
|
1167
|
+
# log, on an input schema or on an output one.
|
|
1168
|
+
# @param state [Hash] the memoized preflight state
|
|
1169
|
+
# @param kind [String] which schema the dialect was declared on
|
|
1170
|
+
# @return [void]
|
|
1171
|
+
# @raise [MCPClient::Errors::ValidationError] when the dialect is unsupported
|
|
1172
|
+
def reject_unsupported_dialect!(tool, state, kind)
|
|
1173
|
+
dialect = state[:dialect]
|
|
1174
|
+
return unless dialect
|
|
1175
|
+
|
|
1176
|
+
raise MCPClient::Errors::ValidationError,
|
|
1177
|
+
"Tool '#{sanitize_peer_log_text(tool.name.to_s)}' #{kind} schema declares the JSON Schema dialect " \
|
|
1178
|
+
"#{sanitize_peer_log_text(dialect.inspect)[0, MAX_VIOLATION_TEXT]}: that dialect is not supported " \
|
|
1179
|
+
"(supported: #{MCPClient::SchemaValidator::SUPPORTED_DIALECTS.join(', ')})"
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
# What the preflight made of a tool's outputSchema, checked once per
|
|
1183
|
+
# definition (keyed like {#input_schema_state}). Only the dialect is kept:
|
|
1184
|
+
# every other reason the schema is unusable is reported through
|
|
1185
|
+
# {#handle_structured_content_violation}, which the host's mode decides.
|
|
1186
|
+
# @param tool [MCPClient::Tool]
|
|
1187
|
+
# @return [Hash] the :dialect that is not supported, if any
|
|
1188
|
+
def output_schema_state(tool)
|
|
1189
|
+
return {} if tool.output_schema.nil?
|
|
1190
|
+
|
|
1191
|
+
@output_schema_dialects ||= {}
|
|
1192
|
+
key = [tool.server&.object_id, tool.name]
|
|
1193
|
+
known = @output_schema_dialects[key]
|
|
1194
|
+
return known if known && known[:identity].equal?(tool_definition_identity(tool))
|
|
1195
|
+
|
|
1196
|
+
preflight = {}
|
|
1197
|
+
MCPClient::SchemaValidator.check_schema(tool.output_schema, preflight)
|
|
1198
|
+
@output_schema_dialects[key] = { identity: tool_definition_identity(tool),
|
|
1199
|
+
dialect: preflight[:unsupported_dialect] }
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
# Validate the result of a call against the definition the request that
|
|
1203
|
+
# was answered actually went out under. A transport's HeaderMismatch
|
|
1204
|
+
# recovery re-derives a call's Mcp-Param-* headers from a refreshed
|
|
1205
|
+
# tools/list, so the attempt that came back may carry an input schema
|
|
1206
|
+
# this client never resolved — and {#validate_params!} refused the
|
|
1207
|
+
# dialect of the definition the call was prepared from, not of the one it
|
|
1208
|
+
# was sent under.
|
|
1209
|
+
# @param tool [MCPClient::Tool] the answering definition
|
|
1210
|
+
# @param result [Object] the raw tools/call result
|
|
1211
|
+
# @return [Object] the result, unchanged
|
|
1212
|
+
# @raise [MCPClient::Errors::ValidationError]
|
|
1213
|
+
def validate_called_result!(tool, result)
|
|
1214
|
+
reject_unsupported_dialect!(tool, input_schema_state(tool), 'input')
|
|
1215
|
+
validate_structured_content!(tool, result)
|
|
1216
|
+
end
|
|
1217
|
+
|
|
1218
|
+
# @param srv [MCPClient::ServerBase] the transport
|
|
1219
|
+
# @return [Boolean] whether the session was negotiated to a revision
|
|
1220
|
+
# before 2026-07-28 (a transport that cannot say is not assumed legacy)
|
|
1221
|
+
def legacy_server?(srv)
|
|
1222
|
+
!srv.nil? && srv.respond_to?(:modern?) && srv.modern? == false
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
# The token naming a tool definition ({MCPClient::Tool#schema_identity});
|
|
1226
|
+
# a tool-like object without one is identified by itself.
|
|
1227
|
+
# @param tool [MCPClient::Tool, Object]
|
|
1228
|
+
# @return [Object]
|
|
1229
|
+
def tool_definition_identity(tool)
|
|
1230
|
+
tool.respond_to?(:schema_identity) ? tool.schema_identity : tool
|
|
1231
|
+
end
|
|
1232
|
+
|
|
945
1233
|
# Warn (in both :warn and :strict modes) when a tool's output schema uses
|
|
946
1234
|
# JSON Schema keywords the built-in validator cannot evaluate, so partial
|
|
947
|
-
# coverage is never silent.
|
|
1235
|
+
# coverage is never silent. The schema is scanned once per definition
|
|
1236
|
+
# (keyed like {#warn_unusable_input_schema}), not on every result.
|
|
948
1237
|
# @param tool [MCPClient::Tool] the tool whose output schema is being used
|
|
949
|
-
# @return [
|
|
1238
|
+
# @return [Array<String>] the unsupported keywords the schema uses
|
|
950
1239
|
def warn_partial_schema_coverage(tool)
|
|
1240
|
+
@output_schema_coverage ||= {}
|
|
1241
|
+
key = [tool.server&.object_id, tool.name]
|
|
1242
|
+
known = @output_schema_coverage[key]
|
|
1243
|
+
return known[:unsupported] if known && known[:identity].equal?(tool_definition_identity(tool))
|
|
1244
|
+
|
|
951
1245
|
unsupported = MCPClient::SchemaValidator.unsupported_keywords(tool.output_schema)
|
|
952
|
-
|
|
1246
|
+
@output_schema_coverage[key] = { identity: tool_definition_identity(tool), unsupported: unsupported }
|
|
1247
|
+
return unsupported if unsupported.empty?
|
|
953
1248
|
|
|
954
1249
|
@logger.warn(
|
|
955
|
-
"Structured content check for tool '#{tool.name}': validation is partial:
|
|
1250
|
+
"Structured content check for tool '#{sanitize_peer_log_text(tool.name.to_s)}': validation is partial: " \
|
|
1251
|
+
'schema uses unsupported ' \
|
|
956
1252
|
"keywords: #{unsupported.join(', ')} (full JSON Schema 2020-12 evaluation is not implemented, so " \
|
|
957
1253
|
'conforming-looking data may still violate the schema)'
|
|
958
1254
|
)
|
|
1255
|
+
unsupported
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
# :strict is a gate. A schema using an assertion this validator does not
|
|
1259
|
+
# evaluate (a dynamic reference only the evaluation path could bind) cannot
|
|
1260
|
+
# be shown to accept the result, and a result not shown to conform is
|
|
1261
|
+
# refused there — a warning beside a returned value was a silent pass in
|
|
1262
|
+
# everything but the log. A keyword that only annotates (`format`,
|
|
1263
|
+
# `contentSchema`) decides nothing and does not refuse.
|
|
1264
|
+
# @param tool [MCPClient::Tool] the tool whose output schema is being used
|
|
1265
|
+
# @param unsupported [Array<String>] what {#warn_partial_schema_coverage} found
|
|
1266
|
+
# @return [void]
|
|
1267
|
+
# @raise [MCPClient::Errors::ValidationError] in :strict mode
|
|
1268
|
+
def reject_partial_schema_coverage!(tool, unsupported)
|
|
1269
|
+
return unless @validate_structured_content == :strict
|
|
1270
|
+
|
|
1271
|
+
assertions = unsupported - MCPClient::SchemaValidator::ANNOTATION_KEYWORDS
|
|
1272
|
+
return if assertions.empty?
|
|
1273
|
+
|
|
1274
|
+
raise MCPClient::Errors::ValidationError,
|
|
1275
|
+
"Structured content for tool '#{sanitize_peer_log_text(tool.name.to_s)}' cannot be checked against " \
|
|
1276
|
+
'its output schema: the schema uses keywords this validator does not evaluate ' \
|
|
1277
|
+
"(#{assertions.join(', ')}), so the result is not shown to conform"
|
|
959
1278
|
end
|
|
960
1279
|
|
|
961
1280
|
# Log a structured-content conformance violation and, in :strict mode,
|
|
@@ -1006,60 +1325,55 @@ module MCPClient
|
|
|
1006
1325
|
matching_tools.first
|
|
1007
1326
|
end
|
|
1008
1327
|
|
|
1009
|
-
#
|
|
1010
|
-
#
|
|
1011
|
-
#
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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")
|
|
1328
|
+
# Empty the tool cache and move its generation on, so a list_tools that
|
|
1329
|
+
# is already fetching does not put the emptied definitions back.
|
|
1330
|
+
# @return [void]
|
|
1331
|
+
def clear_tool_cache
|
|
1332
|
+
@cache_mutex.synchronize do
|
|
1333
|
+
@cache_version += 1
|
|
1334
|
+
@tool_cache.clear
|
|
1335
|
+
@cache_params.delete(:tools)
|
|
1336
|
+
@tool_cache_generation += 1
|
|
1048
1337
|
end
|
|
1049
|
-
|
|
1050
|
-
MCPClient::Errors::TaskError.new("Error #{action} task '#{task_id}': #{error.message}")
|
|
1051
1338
|
end
|
|
1052
1339
|
|
|
1053
|
-
#
|
|
1054
|
-
#
|
|
1055
|
-
#
|
|
1056
|
-
#
|
|
1340
|
+
# The definition the transport's own tools/call request went out under,
|
|
1341
|
+
# taken from the transport so it is spent on this one re-resolve.
|
|
1342
|
+
#
|
|
1343
|
+
# It is read back rather than re-listed because a list is only ever the
|
|
1344
|
+
# transport's *current* answer: a tools/list_changed that raced the call
|
|
1345
|
+
# has already replaced it, and the server answered under the definition
|
|
1346
|
+
# the request carried.
|
|
1347
|
+
# @param server [MCPClient::ServerBase] the transport the call went to
|
|
1348
|
+
# @param tool_name [String] the tool being re-resolved
|
|
1349
|
+
# @return [MCPClient::Tool, nil] the definition the answering attempt went
|
|
1350
|
+
# out under, or nil when the transport recorded none (or recorded that
|
|
1351
|
+
# its list did not carry the tool), in which case the definition the
|
|
1352
|
+
# caller resolved before the call stands
|
|
1353
|
+
def called_tool_definition(server, tool_name)
|
|
1354
|
+
return nil unless server.respond_to?(:take_called_tool_definition, true)
|
|
1355
|
+
|
|
1356
|
+
server.send(:take_called_tool_definition, tool_name.to_s)&.first
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
# Forget the once-per-definition schema checks of the tool definitions a
|
|
1360
|
+
# cache slice no longer holds. Their keys name a tool definition, so a
|
|
1361
|
+
# server that keeps renaming its tools would otherwise grow both memos
|
|
1362
|
+
# without bound; a definition still served is checked again on its next
|
|
1363
|
+
# use, which its identity token decides anyway.
|
|
1364
|
+
# @param server [MCPClient::ServerBase, nil] the server whose entries go,
|
|
1365
|
+
# or nil for every server
|
|
1057
1366
|
# @return [void]
|
|
1058
|
-
def
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1367
|
+
def forget_schema_checks(server = nil)
|
|
1368
|
+
[@input_schema_warnings, @output_schema_coverage, @output_schema_dialects].each do |memo|
|
|
1369
|
+
next unless memo
|
|
1370
|
+
|
|
1371
|
+
if server
|
|
1372
|
+
memo.delete_if { |(server_id, _name), _| server_id == server.object_id }
|
|
1373
|
+
else
|
|
1374
|
+
memo.clear
|
|
1375
|
+
end
|
|
1376
|
+
end
|
|
1063
1377
|
end
|
|
1064
1378
|
|
|
1065
1379
|
# Generate a cache key for server-specific items
|
|
@@ -1131,15 +1445,18 @@ module MCPClient
|
|
|
1131
1445
|
# Supports both form mode (structured data) and URL mode (out-of-band interaction).
|
|
1132
1446
|
# @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
|
|
1133
1447
|
# @param params [Hash] the elicitation parameters
|
|
1448
|
+
# @param server [MCPClient::ServerBase, nil] the server that asked, so the
|
|
1449
|
+
# URL-mode host contract can follow its protocol era; nil (an era that
|
|
1450
|
+
# was never established) keeps the 2025-11-25 contract
|
|
1134
1451
|
# @return [Hash] the elicitation response
|
|
1135
|
-
def handle_elicitation_request(_request_id, params)
|
|
1452
|
+
def handle_elicitation_request(_request_id, params, server = nil)
|
|
1136
1453
|
mode = params['mode'] || 'form'
|
|
1137
1454
|
# MCP 2025-11-25: requests with a mode not declared in client
|
|
1138
1455
|
# capabilities MUST be rejected with -32602 (Invalid params). This check
|
|
1139
1456
|
# precedes everything else — an undeclared mode is -32602 even when no
|
|
1140
1457
|
# handler is configured.
|
|
1141
1458
|
unless SUPPORTED_ELICITATION_MODES.include?(mode)
|
|
1142
|
-
@logger.warn("Rejecting elicitation request with unsupported mode '#{mode}'")
|
|
1459
|
+
@logger.warn("Rejecting elicitation request with unsupported mode '#{sanitize_peer_log_text(mode.to_s)}'")
|
|
1143
1460
|
return jsonrpc_error_result(-32_602, "Elicitation mode '#{mode}' is not supported")
|
|
1144
1461
|
end
|
|
1145
1462
|
|
|
@@ -1154,16 +1471,20 @@ module MCPClient
|
|
|
1154
1471
|
|
|
1155
1472
|
begin
|
|
1156
1473
|
result = if mode == 'url'
|
|
1157
|
-
handle_url_elicitation(params, message)
|
|
1474
|
+
handle_url_elicitation(params, message, server)
|
|
1158
1475
|
else
|
|
1159
1476
|
handle_form_elicitation(params, message)
|
|
1160
1477
|
end
|
|
1161
1478
|
|
|
1162
1479
|
format_elicitation_response(result, params)
|
|
1163
1480
|
rescue StandardError => e
|
|
1481
|
+
# Same reasoning as the sampling path: the handler's exception text is
|
|
1482
|
+
# host-internal and must not cross to the server. Because this rescue
|
|
1483
|
+
# runs inside the client, the transports' constant-message rescues
|
|
1484
|
+
# never see it — so it has to be constant here.
|
|
1164
1485
|
@logger.error("Elicitation handler error: #{e.message}")
|
|
1165
1486
|
@logger.debug(e.backtrace.join("\n"))
|
|
1166
|
-
jsonrpc_error_result(-32_603,
|
|
1487
|
+
jsonrpc_error_result(-32_603, 'Elicitation handler error')
|
|
1167
1488
|
end
|
|
1168
1489
|
end
|
|
1169
1490
|
|
|
@@ -1187,7 +1508,9 @@ module MCPClient
|
|
|
1187
1508
|
# Validate schema if present
|
|
1188
1509
|
if schema
|
|
1189
1510
|
schema_errors = ElicitationValidator.validate_schema(schema)
|
|
1190
|
-
|
|
1511
|
+
unless schema_errors.empty?
|
|
1512
|
+
@logger.warn("Elicitation schema validation warnings: #{sanitize_peer_log_text(schema_errors.join('; '))}")
|
|
1513
|
+
end
|
|
1191
1514
|
end
|
|
1192
1515
|
|
|
1193
1516
|
# Call the user-defined handler
|
|
@@ -1206,10 +1529,10 @@ module MCPClient
|
|
|
1206
1529
|
# Handle URL mode elicitation (MCP 2025-11-25)
|
|
1207
1530
|
# @param params [Hash] the elicitation parameters
|
|
1208
1531
|
# @param message [String] the human-readable message
|
|
1532
|
+
# @param server [MCPClient::ServerBase, nil] the server that asked
|
|
1209
1533
|
# @return [Object] handler result
|
|
1210
|
-
def handle_url_elicitation(params, message)
|
|
1211
|
-
|
|
1212
|
-
elicitation_id = params['elicitationId']
|
|
1534
|
+
def handle_url_elicitation(params, message, server = nil)
|
|
1535
|
+
url_params = url_elicitation_metadata(params, server)
|
|
1213
1536
|
|
|
1214
1537
|
# Call handler with URL-mode specific params
|
|
1215
1538
|
case @elicitation_handler.arity
|
|
@@ -1218,11 +1541,44 @@ module MCPClient
|
|
|
1218
1541
|
when 1
|
|
1219
1542
|
@elicitation_handler.call(message)
|
|
1220
1543
|
when 2, -1
|
|
1221
|
-
@elicitation_handler.call(message,
|
|
1544
|
+
@elicitation_handler.call(message, url_params)
|
|
1222
1545
|
else
|
|
1223
|
-
@elicitation_handler.call(message,
|
|
1224
|
-
|
|
1546
|
+
@elicitation_handler.call(message, url_params, params['metadata'])
|
|
1547
|
+
end
|
|
1548
|
+
end
|
|
1549
|
+
|
|
1550
|
+
# The URL-mode metadata handed to the host. MCP 2026-07-28 (changelog,
|
|
1551
|
+
# minor change 11) removed the `elicitationId` field along with
|
|
1552
|
+
# `notifications/elicitation/complete`: under the multi round-trip
|
|
1553
|
+
# requests pattern the client learns the outcome by retrying the original
|
|
1554
|
+
# request, and a server that must correlate an elicitation across retries
|
|
1555
|
+
# carries its own identifier in `requestState`. So a modern server's
|
|
1556
|
+
# contract has no such key at all — not even a nil one — and a
|
|
1557
|
+
# non-conforming modern server that sends the field anyway cannot smuggle
|
|
1558
|
+
# a correlation id to the host through it. For a server on an earlier
|
|
1559
|
+
# revision the field is part of the protocol and the contract is
|
|
1560
|
+
# unchanged, key present (nil when the server sent none) and all; a nil
|
|
1561
|
+
# server (an era that was never established) is treated the same way.
|
|
1562
|
+
# @param params [Hash] the elicitation parameters
|
|
1563
|
+
# @param server [MCPClient::ServerBase, nil] the server that asked
|
|
1564
|
+
# @return [Hash] the metadata hash for the host's handler
|
|
1565
|
+
def url_elicitation_metadata(params, server)
|
|
1566
|
+
metadata = { 'mode' => 'url', 'url' => params['url'] }
|
|
1567
|
+
return metadata.merge('elicitationId' => params['elicitationId']) unless modern_server?(server)
|
|
1568
|
+
|
|
1569
|
+
if params.key?('elicitationId')
|
|
1570
|
+
# Dropping the field is the protocol decision; saying so is a
|
|
1571
|
+
# courtesy. A logger that raises costs the notice, never the
|
|
1572
|
+
# elicitation — the value is a server-chosen correlation id, so the
|
|
1573
|
+
# message names the field and never quotes it.
|
|
1574
|
+
begin
|
|
1575
|
+
@logger.warn('Ignoring elicitationId on a URL-mode elicitation request: MCP 2026-07-28 removed the field ' \
|
|
1576
|
+
'(the outcome is learned by retrying the original request; correlate via requestState)')
|
|
1577
|
+
rescue StandardError
|
|
1578
|
+
nil
|
|
1579
|
+
end
|
|
1225
1580
|
end
|
|
1581
|
+
metadata
|
|
1226
1582
|
end
|
|
1227
1583
|
|
|
1228
1584
|
# Format and validate the elicitation response
|
|
@@ -1230,7 +1586,11 @@ module MCPClient
|
|
|
1230
1586
|
# @param params [Hash] original request params (for schema validation)
|
|
1231
1587
|
# @return [Hash] formatted response
|
|
1232
1588
|
def format_elicitation_response(result, params)
|
|
1233
|
-
response =
|
|
1589
|
+
response = if (params['mode'] || 'form') == 'url'
|
|
1590
|
+
normalize_url_elicitation_result(result)
|
|
1591
|
+
else
|
|
1592
|
+
normalize_elicitation_result(result)
|
|
1593
|
+
end
|
|
1234
1594
|
|
|
1235
1595
|
# Per the ElicitResult schema, content is only present when the action
|
|
1236
1596
|
# is accept and the mode was form; it is omitted for decline/cancel and
|
|
@@ -1255,6 +1615,29 @@ module MCPClient
|
|
|
1255
1615
|
response
|
|
1256
1616
|
end
|
|
1257
1617
|
|
|
1618
|
+
# A URL-mode elicitation reports the user's consent to open the URL, so
|
|
1619
|
+
# only an explicit answer counts: `true` or an ElicitResult with an
|
|
1620
|
+
# `action` of accept/decline/cancel. Anything else — a bare value, a form
|
|
1621
|
+
# style content hash, nil — is not consent and is answered with cancel.
|
|
1622
|
+
# @param result [Object] handler result
|
|
1623
|
+
# @return [Hash] normalized ElicitResult without content
|
|
1624
|
+
def normalize_url_elicitation_result(result)
|
|
1625
|
+
return { 'action' => 'accept' } if result == true
|
|
1626
|
+
|
|
1627
|
+
action = result.is_a?(Hash) ? (result['action'] || result[:action]) : nil
|
|
1628
|
+
if %w[accept decline cancel].include?(action.to_s)
|
|
1629
|
+
# ElicitResult carries `_meta` in every mode; only `content` is
|
|
1630
|
+
# form-mode-specific, and format_elicitation_response strips it.
|
|
1631
|
+
meta = result['_meta'] || result[:_meta]
|
|
1632
|
+
return { 'action' => action.to_s, '_meta' => meta }.compact
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
unless result.nil? || result == false
|
|
1636
|
+
@logger.warn('URL-mode elicitation handler gave no explicit action; answering cancel (consent is explicit)')
|
|
1637
|
+
end
|
|
1638
|
+
{ 'action' => 'cancel' }
|
|
1639
|
+
end
|
|
1640
|
+
|
|
1258
1641
|
# Normalize a handler's return value into a string-keyed ElicitResult
|
|
1259
1642
|
# shape, so mixed or symbol keys cannot bypass content handling.
|
|
1260
1643
|
# @param result [Object] handler result
|
|
@@ -1291,14 +1674,21 @@ module MCPClient
|
|
|
1291
1674
|
ElicitationValidator.validate_content(response['content'], schema)
|
|
1292
1675
|
end
|
|
1293
1676
|
|
|
1294
|
-
# Ensure the action value conforms to MCP spec (accept, decline, cancel)
|
|
1295
|
-
#
|
|
1677
|
+
# Ensure the action value conforms to MCP spec (accept, decline, cancel).
|
|
1678
|
+
# An action outside that set is not consent the user gave, so it is
|
|
1679
|
+
# answered as cancel — the verdict URL mode reaches for the same handler
|
|
1680
|
+
# result — rather than rewritten into an accept. (A handler that returns
|
|
1681
|
+
# bare content and no action at all is the documented convenience shape
|
|
1682
|
+
# and never reaches here; see #normalize_elicitation_result.)
|
|
1683
|
+
# @param result [Hash] the normalized ElicitResult
|
|
1684
|
+
# @return [Hash] the result, with an unrecognized action answered as cancel
|
|
1296
1685
|
def normalised_action_response(result)
|
|
1297
1686
|
action = result['action']
|
|
1298
1687
|
return result if %w[accept decline cancel].include?(action)
|
|
1299
1688
|
|
|
1300
|
-
@logger.warn("Unknown elicitation action '#{action}'
|
|
1301
|
-
|
|
1689
|
+
@logger.warn("Unknown elicitation action '#{sanitize_peer_log_text(action.to_s)}'; answering cancel " \
|
|
1690
|
+
'(consent is explicit)')
|
|
1691
|
+
result.merge('action' => 'cancel')
|
|
1302
1692
|
end
|
|
1303
1693
|
|
|
1304
1694
|
# Normalize roots array - convert Hashes to Root objects (MCP 2025-06-18)
|
|
@@ -1327,15 +1717,35 @@ module MCPClient
|
|
|
1327
1717
|
{ 'roots' => @roots.map(&:to_h) }
|
|
1328
1718
|
end
|
|
1329
1719
|
|
|
1720
|
+
# Whether a server may be told the roots list changed. MCP forbids using a
|
|
1721
|
+
# capability that was not declared during initialization, so the
|
|
1722
|
+
# notification goes only to sessions whose declared client capabilities
|
|
1723
|
+
# include roots: a transport that registers the handlers for the modern
|
|
1724
|
+
# multi round-trip pattern but has no server-request channel to serve
|
|
1725
|
+
# them on (plain HTTP on a legacy session) declares none, however it
|
|
1726
|
+
# answers respond_to?.
|
|
1727
|
+
# @param server [Object] an MCP server transport
|
|
1728
|
+
# @return [Boolean]
|
|
1729
|
+
def roots_list_changed_recipient?(server)
|
|
1730
|
+
return false unless server.respond_to?(:on_roots_list_request)
|
|
1731
|
+
# notifications/roots/list_changed was removed in MCP 2026-07-28: a
|
|
1732
|
+
# modern server reads roots through the multi round-trip pattern when it
|
|
1733
|
+
# needs them, and has no channel to be told they changed.
|
|
1734
|
+
# Judged by the ESTABLISHED era: while a probe is in flight the version
|
|
1735
|
+
# is only a proposal, and a server that then falls back to the handshake
|
|
1736
|
+
# can still ask for roots — so the transport, which settles the era
|
|
1737
|
+
# before it writes anything, makes the call.
|
|
1738
|
+
return false if server.respond_to?(:protocol_era) && server.protocol_era == :modern
|
|
1739
|
+
return true unless server.respond_to?(:client_capabilities)
|
|
1740
|
+
|
|
1741
|
+
server.client_capabilities.key?('roots')
|
|
1742
|
+
end
|
|
1743
|
+
|
|
1330
1744
|
# Send notification to all servers that roots have changed (MCP 2025-06-18)
|
|
1331
1745
|
# @return [void]
|
|
1332
1746
|
def notify_roots_changed
|
|
1333
1747
|
@servers.each do |server|
|
|
1334
|
-
|
|
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)
|
|
1748
|
+
next unless roots_list_changed_recipient?(server)
|
|
1339
1749
|
|
|
1340
1750
|
begin
|
|
1341
1751
|
server.rpc_notify('notifications/roots/list_changed', {})
|
|
@@ -1346,50 +1756,13 @@ module MCPClient
|
|
|
1346
1756
|
end
|
|
1347
1757
|
end
|
|
1348
1758
|
|
|
1349
|
-
#
|
|
1350
|
-
# @
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
def handle_sampling_request(_request_id, params)
|
|
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".
|
|
1358
|
-
unless @sampling_handler
|
|
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')
|
|
1372
|
-
end
|
|
1759
|
+
# @param block [Object] a content block
|
|
1760
|
+
# @return [String, nil] its type, nil unless it is an object with a String type
|
|
1761
|
+
def sampling_block_type(block)
|
|
1762
|
+
return nil unless block.is_a?(Hash)
|
|
1373
1763
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
system_prompt = params['systemPrompt']
|
|
1377
|
-
max_tokens = params['maxTokens']
|
|
1378
|
-
|
|
1379
|
-
begin
|
|
1380
|
-
# Call the user-defined handler with parameters based on arity
|
|
1381
|
-
result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
|
|
1382
|
-
|
|
1383
|
-
# Validate and format response
|
|
1384
|
-
validate_sampling_response(result)
|
|
1385
|
-
rescue StandardError => e
|
|
1386
|
-
@logger.error("Sampling handler error: #{e.message}")
|
|
1387
|
-
@logger.debug(e.backtrace.join("\n"))
|
|
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}")
|
|
1392
|
-
end
|
|
1764
|
+
type = block['type'] || block[:type]
|
|
1765
|
+
type.is_a?(String) ? type : nil
|
|
1393
1766
|
end
|
|
1394
1767
|
|
|
1395
1768
|
# Call sampling handler with appropriate arity
|