ruby-mcp-client 2.1.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 +825 -48
- 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 +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- 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 +796 -518
- 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/errors.rb +474 -7
- 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 +666 -120
- 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 +900 -13
- 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 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- 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 +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- 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 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
data/lib/mcp_client/client.rb
CHANGED
|
@@ -2,28 +2,66 @@
|
|
|
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.
|
|
@@ -38,6 +76,11 @@ module MCPClient
|
|
|
38
76
|
# Placeholder written in place of a redacted value.
|
|
39
77
|
REDACTED = '[REDACTED]'
|
|
40
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
|
+
|
|
41
84
|
# Maximum characters of a peer-supplied log message written to the host
|
|
42
85
|
# log. The remote server controls this content, so an unbounded message
|
|
43
86
|
# would let it inflate log storage at will.
|
|
@@ -47,8 +90,13 @@ module MCPClient
|
|
|
47
90
|
# @param mcp_server_configs [Array<Hash>] configurations for MCP servers
|
|
48
91
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
|
49
92
|
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
|
|
50
|
-
# @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
|
|
51
|
-
#
|
|
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.
|
|
52
100
|
# @param sampling_supports_tools [Boolean] whether the sampling handler supports tool use
|
|
53
101
|
# (MCP 2025-11-25 / SEP-1577); declares the sampling.tools capability and forwards
|
|
54
102
|
# tools/toolChoice params to the handler instead of rejecting tool-enabled requests
|
|
@@ -58,14 +106,23 @@ module MCPClient
|
|
|
58
106
|
# structuredContent does not match the tool's declared outputSchema (MCP 2025-11-25:
|
|
59
107
|
# "Clients SHOULD validate structured results against this schema"): :warn (default)
|
|
60
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
|
|
61
116
|
def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil,
|
|
62
|
-
sampling_supports_tools: false, client_info: nil, validate_structured_content: :warn
|
|
117
|
+
sampling_supports_tools: false, client_info: nil, validate_structured_content: :warn,
|
|
118
|
+
request_meta: nil, extensions: nil)
|
|
63
119
|
unless STRUCTURED_CONTENT_MODES.include?(validate_structured_content)
|
|
64
120
|
raise ArgumentError, "validate_structured_content must be one of #{STRUCTURED_CONTENT_MODES.inspect}, " \
|
|
65
121
|
"got #{validate_structured_content.inspect}"
|
|
66
122
|
end
|
|
67
123
|
|
|
68
124
|
@validate_structured_content = validate_structured_content
|
|
125
|
+
@extensions = normalize_extensions(extensions)
|
|
69
126
|
# Preserve a caller-supplied logger's formatter (only tag progname), and
|
|
70
127
|
# install the default formatter solely on a logger we create ourselves.
|
|
71
128
|
# Overwriting the formatter of an application's logger would silently
|
|
@@ -83,6 +140,28 @@ module MCPClient
|
|
|
83
140
|
MCPClient::ServerFactory.create(config, logger: @logger)
|
|
84
141
|
end
|
|
85
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
|
|
86
165
|
# Active progressToken -> callback registrations (MCP progress utility)
|
|
87
166
|
@progress_callbacks = {}
|
|
88
167
|
@progress_mutex = Mutex.new
|
|
@@ -92,28 +171,29 @@ module MCPClient
|
|
|
92
171
|
@notification_listeners = []
|
|
93
172
|
# Elicitation handler (MCP 2025-06-18)
|
|
94
173
|
@elicitation_handler = elicitation_handler
|
|
95
|
-
# Sampling handler (MCP 2025-11-25)
|
|
174
|
+
# Sampling handler (MCP 2025-11-25; deprecated in 2026-07-28, SEP-2577)
|
|
96
175
|
@sampling_handler = sampling_handler
|
|
176
|
+
MCPClient::Deprecations.warn(:sampling, @logger) if sampling_handler
|
|
97
177
|
# Whether the sampling handler supports tool use (SEP-1577)
|
|
98
178
|
@sampling_supports_tools = sampling_supports_tools
|
|
99
|
-
# Roots (MCP 2025-06-18)
|
|
179
|
+
# Roots (MCP 2025-06-18; deprecated in 2026-07-28, SEP-2577)
|
|
100
180
|
@roots = normalize_roots(roots)
|
|
181
|
+
MCPClient::Deprecations.warn(:roots, @logger) unless @roots.empty?
|
|
101
182
|
# Register default and user-defined notification handlers on each server
|
|
102
183
|
@servers.each do |server|
|
|
103
|
-
|
|
104
|
-
server
|
|
105
|
-
server.on_notification do |method, params|
|
|
106
|
-
# Default notification processing (e.g., cache invalidation, logging)
|
|
107
|
-
process_notification(server, method, params)
|
|
108
|
-
# Invoke user-defined listeners
|
|
109
|
-
@notification_listeners.each { |cb| cb.call(server, method, params) }
|
|
110
|
-
end
|
|
184
|
+
configure_server_identity(server, client_info, request_meta)
|
|
185
|
+
register_notification_handlers(server)
|
|
111
186
|
# Register feature callbacks only for features the host actually
|
|
112
187
|
# supports: transports derive their declared client capabilities from
|
|
113
188
|
# the callbacks registered before connecting, and MCP forbids using
|
|
114
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).
|
|
115
193
|
if @elicitation_handler && server.respond_to?(:on_elicitation_request)
|
|
116
|
-
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
|
|
117
197
|
end
|
|
118
198
|
# The client always implements the roots feature (roots/list and
|
|
119
199
|
# list_changed notifications), independent of the current roots list.
|
|
@@ -134,29 +214,14 @@ module MCPClient
|
|
|
134
214
|
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
|
135
215
|
# @raise [MCPClient::Errors::PromptGetError] if no prompts could be retrieved from any server
|
|
136
216
|
def list_prompts(cache: true)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
servers.each do |server|
|
|
143
|
-
server.list_prompts.each do |prompt|
|
|
144
|
-
cache_key = cache_key_for(server, prompt.name)
|
|
145
|
-
@prompt_cache[cache_key] = prompt
|
|
146
|
-
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
|
|
147
221
|
end
|
|
148
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
149
|
-
# Fast-fail on authorization errors for better user experience
|
|
150
|
-
# If this is the first server or we haven't collected any prompts yet,
|
|
151
|
-
# raise the auth error directly to avoid cascading error messages
|
|
152
|
-
raise e if e.message.include?('Authorization failed') && prompts.empty?
|
|
153
|
-
|
|
154
|
-
# Store the error and try other servers
|
|
155
|
-
connection_errors << e
|
|
156
|
-
@logger.error("Server error: #{e.message}")
|
|
157
|
-
end
|
|
158
222
|
|
|
159
|
-
|
|
223
|
+
collect_prompts_from_servers(cache)
|
|
224
|
+
end
|
|
160
225
|
end
|
|
161
226
|
|
|
162
227
|
# Gets a specific prompt by name with the given parameters
|
|
@@ -214,44 +279,24 @@ module MCPClient
|
|
|
214
279
|
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
|
215
280
|
# @raise [MCPClient::Errors::ResourceReadError] if no resources could be retrieved from any server
|
|
216
281
|
def list_resources(cache: true, cursor: nil)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# Use cache if available and no cursor
|
|
228
|
-
return { 'resources' => @resource_cache.values, 'nextCursor' => nil } if cache && !@resource_cache.empty?
|
|
229
|
-
|
|
230
|
-
resources = []
|
|
231
|
-
connection_errors = []
|
|
232
|
-
|
|
233
|
-
servers.each do |server|
|
|
234
|
-
result = server.list_resources
|
|
235
|
-
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
|
|
236
291
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
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 }
|
|
241
296
|
end
|
|
242
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
243
|
-
# Fast-fail on authorization errors for better user experience
|
|
244
|
-
# If this is the first server or we haven't collected any resources yet,
|
|
245
|
-
# raise the auth error directly to avoid cascading error messages
|
|
246
|
-
raise e if e.message.include?('Authorization failed') && resources.empty?
|
|
247
|
-
|
|
248
|
-
# Store the error and try other servers
|
|
249
|
-
connection_errors << e
|
|
250
|
-
@logger.error("Server error: #{e.message}")
|
|
251
|
-
end
|
|
252
297
|
|
|
253
|
-
|
|
254
|
-
|
|
298
|
+
collect_resources_from_servers(cache)
|
|
299
|
+
end
|
|
255
300
|
end
|
|
256
301
|
|
|
257
302
|
# Reads a specific resource by URI
|
|
@@ -277,36 +322,14 @@ module MCPClient
|
|
|
277
322
|
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
|
278
323
|
# @raise [MCPClient::Errors::ToolCallError] if no tools could be retrieved from any server
|
|
279
324
|
def list_tools(cache: true)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
servers.each do |server|
|
|
286
|
-
server.list_tools.each do |tool|
|
|
287
|
-
cache_key = cache_key_for(server, tool.name)
|
|
288
|
-
@tool_cache[cache_key] = tool
|
|
289
|
-
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
|
|
290
329
|
end
|
|
291
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
292
|
-
# Fast-fail on authorization errors for better user experience
|
|
293
|
-
# If this is the first server or we haven't collected any tools yet,
|
|
294
|
-
# raise the auth error directly to avoid cascading error messages
|
|
295
|
-
raise e if e.message.include?('Authorization failed') && tools.empty?
|
|
296
|
-
|
|
297
|
-
# Store the error and try other servers
|
|
298
|
-
connection_errors << e
|
|
299
|
-
@logger.error("Server error: #{e.message}")
|
|
300
|
-
end
|
|
301
330
|
|
|
302
|
-
|
|
303
|
-
if tools.empty? && !servers.empty?
|
|
304
|
-
raise connection_errors.first if connection_errors.any?
|
|
305
|
-
|
|
306
|
-
@logger.warn('No tools found from any server.')
|
|
331
|
+
collect_tools_from_servers(cache)
|
|
307
332
|
end
|
|
308
|
-
|
|
309
|
-
tools
|
|
310
333
|
end
|
|
311
334
|
|
|
312
335
|
# Calls a specific tool by name with the given parameters
|
|
@@ -314,6 +337,9 @@ module MCPClient
|
|
|
314
337
|
# @param parameters [Hash] the parameters to pass to the tool
|
|
315
338
|
# @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
|
|
316
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)
|
|
317
343
|
def call_tool(tool_name, parameters, server: nil, progress: nil)
|
|
318
344
|
tool = resolve_tool(tool_name, server: server)
|
|
319
345
|
|
|
@@ -329,20 +355,51 @@ module MCPClient
|
|
|
329
355
|
# request _meta and route matching notifications/progress to the
|
|
330
356
|
# caller's callback while the request is active.
|
|
331
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
|
|
332
384
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
#
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
|
|
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)
|
|
343
402
|
end
|
|
344
|
-
|
|
345
|
-
validate_structured_content!(tool, result)
|
|
346
403
|
end
|
|
347
404
|
|
|
348
405
|
# Convert MCP tools to OpenAI function specifications
|
|
@@ -375,14 +432,38 @@ module MCPClient
|
|
|
375
432
|
# Clean up all server connections
|
|
376
433
|
def cleanup
|
|
377
434
|
servers.each(&:cleanup)
|
|
435
|
+
# The transports forgot their results; the slices built from them go too.
|
|
436
|
+
clear_cache
|
|
437
|
+
clear_task_states
|
|
378
438
|
end
|
|
379
439
|
|
|
380
|
-
#
|
|
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.
|
|
381
446
|
# @return [void]
|
|
382
447
|
def clear_cache
|
|
383
|
-
|
|
384
|
-
@
|
|
385
|
-
|
|
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
|
|
386
467
|
end
|
|
387
468
|
|
|
388
469
|
# Register a callback for JSON-RPC notifications from servers
|
|
@@ -392,11 +473,44 @@ module MCPClient
|
|
|
392
473
|
@notification_listeners << block
|
|
393
474
|
end
|
|
394
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
|
+
|
|
395
504
|
# Set the roots for this client (MCP 2025-06-18)
|
|
396
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.
|
|
397
510
|
# @param new_roots [Array<MCPClient::Root, Hash>] the new roots to set
|
|
398
511
|
# @return [void]
|
|
399
512
|
def roots=(new_roots)
|
|
513
|
+
MCPClient::Deprecations.warn(:roots, @logger)
|
|
400
514
|
@roots = normalize_roots(new_roots)
|
|
401
515
|
# Notify servers that roots have changed
|
|
402
516
|
notify_roots_changed
|
|
@@ -457,8 +571,20 @@ module MCPClient
|
|
|
457
571
|
raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
|
|
458
572
|
|
|
459
573
|
begin
|
|
460
|
-
|
|
461
|
-
|
|
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)
|
|
462
588
|
rescue MCPClient::Errors::ConnectionError => e
|
|
463
589
|
# Add server identity information to the error for better context
|
|
464
590
|
server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
|
|
@@ -526,171 +652,57 @@ module MCPClient
|
|
|
526
652
|
srv.complete(ref: ref, argument: argument, context: context)
|
|
527
653
|
end
|
|
528
654
|
|
|
529
|
-
#
|
|
530
|
-
#
|
|
531
|
-
#
|
|
532
|
-
#
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
# @param tool_name [String] the name of the tool to call
|
|
537
|
-
# @param parameters [Hash] the parameters to pass to the tool
|
|
538
|
-
# @param ttl [Integer, nil] optional requested task lifetime in milliseconds
|
|
539
|
-
# @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
|
|
540
|
-
# @return [MCPClient::Task] the created task (status typically 'working')
|
|
541
|
-
# @raise [MCPClient::Errors::ToolNotFound] if the tool is not found
|
|
542
|
-
# @raise [MCPClient::Errors::ValidationError] if required parameters are missing
|
|
543
|
-
# @raise [MCPClient::Errors::TaskError] if the server or tool does not support tasks, or creation fails
|
|
544
|
-
def call_tool_as_task(tool_name, parameters, ttl: nil, server: nil)
|
|
545
|
-
tool = resolve_tool(tool_name, server: server)
|
|
546
|
-
validate_params!(tool, parameters)
|
|
547
|
-
|
|
548
|
-
srv = tool.server
|
|
549
|
-
raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless srv
|
|
550
|
-
|
|
551
|
-
unless server_supports_task_tool_call?(srv)
|
|
552
|
-
raise MCPClient::Errors::TaskError,
|
|
553
|
-
'Server does not support task-augmented tools/call (no tasks.requests.tools.call capability)'
|
|
554
|
-
end
|
|
555
|
-
unless tool.supports_task?
|
|
556
|
-
raise MCPClient::Errors::TaskError,
|
|
557
|
-
"Tool '#{tool_name}' does not support task execution (execution.taskSupport is forbidden/unset)"
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
task_params = {}
|
|
561
|
-
task_params[:ttl] = ttl if ttl
|
|
562
|
-
# Keep _meta (string or symbol key) as a top-level request field rather
|
|
563
|
-
# than a tool argument, so request metadata is preserved and does not fail
|
|
564
|
-
# tool input-schema validation.
|
|
565
|
-
meta_key = [:_meta, '_meta'].find { |k| parameters.key?(k) }
|
|
566
|
-
arguments = meta_key ? parameters.reject { |k, _| k == meta_key } : parameters
|
|
567
|
-
rpc_params = { name: tool_name, arguments: arguments, task: task_params }
|
|
568
|
-
rpc_params[:_meta] = parameters[meta_key] if meta_key
|
|
569
|
-
|
|
570
|
-
begin
|
|
571
|
-
result = srv.rpc_request('tools/call', rpc_params)
|
|
572
|
-
MCPClient::Task.from_create_result(result, server: srv)
|
|
573
|
-
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
574
|
-
raise MCPClient::Errors::TaskError, "Error creating task for tool '#{tool_name}': #{e.message}"
|
|
575
|
-
end
|
|
576
|
-
end
|
|
577
|
-
|
|
578
|
-
# Get the current state of a task (tasks/get, MCP 2025-11-25)
|
|
579
|
-
# @param task_id [String, MCPClient::Task] the task to query; passing the
|
|
580
|
-
# Task handle returned by #call_tool_as_task routes to its own server
|
|
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)
|
|
581
662
|
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
582
|
-
# @
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
# @
|
|
586
|
-
# @
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
task_id = task_identifier(task_id)
|
|
590
|
-
|
|
591
|
-
begin
|
|
592
|
-
result = srv.rpc_request('tasks/get', { taskId: task_id })
|
|
593
|
-
MCPClient::Task.from_json(result, server: srv)
|
|
594
|
-
rescue MCPClient::Errors::ServerError => e
|
|
595
|
-
raise task_error_from(e, task_id, 'getting')
|
|
596
|
-
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
597
|
-
raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
|
|
598
|
-
end
|
|
599
|
-
end
|
|
600
|
-
|
|
601
|
-
# Retrieve the result of a completed task (tasks/result, MCP 2025-11-25).
|
|
602
|
-
# Returns exactly what the underlying request would have returned (e.g. a
|
|
603
|
-
# CallToolResult hash with 'content'/'isError'/'structuredContent'); it is
|
|
604
|
-
# NOT wrapped in a Task. Blocks on the server until the task is terminal.
|
|
605
|
-
#
|
|
606
|
-
# NOTE: structured-content validation (see #validate_structured_content!)
|
|
607
|
-
# does not cover task-delivered results yet: a task ID alone does not
|
|
608
|
-
# identify which tool (and therefore which outputSchema) produced the
|
|
609
|
-
# result, and the client keeps no task-to-tool registry. Callers who need
|
|
610
|
-
# validation here can run MCPClient::SchemaValidator.validate themselves.
|
|
611
|
-
# @param task_id [String, MCPClient::Task] the task; passing the Task
|
|
612
|
-
# handle returned by #call_tool_as_task routes to its own server
|
|
613
|
-
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
614
|
-
# @return [Object] the underlying task result
|
|
615
|
-
# @raise [ArgumentError] if the server is ambiguous in a multi-server client
|
|
616
|
-
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
617
|
-
# @raise [MCPClient::Errors::TaskError] if retrieval fails
|
|
618
|
-
def get_task_result(task_id, server: nil)
|
|
619
|
-
srv = select_task_server(task_id, server, 'get_task_result')
|
|
620
|
-
task_id = task_identifier(task_id)
|
|
621
|
-
|
|
622
|
-
begin
|
|
623
|
-
srv.rpc_request('tasks/result', { taskId: task_id })
|
|
624
|
-
rescue MCPClient::Errors::ServerError => e
|
|
625
|
-
raise task_error_from(e, task_id, 'getting result for')
|
|
626
|
-
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
627
|
-
raise MCPClient::Errors::TaskError, "Error getting result for task '#{task_id}': #{e.message}"
|
|
628
|
-
end
|
|
629
|
-
end
|
|
630
|
-
|
|
631
|
-
# List tasks known to a server (tasks/list, paginated, MCP 2025-11-25)
|
|
632
|
-
# @param cursor [String, nil] optional pagination cursor
|
|
633
|
-
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
634
|
-
# @return [Hash] { tasks: Array<MCPClient::Task>, next_cursor: String, nil }
|
|
635
|
-
# @raise [MCPClient::Errors::TaskError] if listing fails
|
|
636
|
-
def list_tasks(cursor: nil, server: nil)
|
|
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)
|
|
637
670
|
srv = select_server(server)
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
tasks = (result['tasks'] || []).map { |t| MCPClient::Task.from_json(t, server: srv) }
|
|
645
|
-
{ tasks: tasks, next_cursor: result['nextCursor'] }
|
|
646
|
-
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
647
|
-
raise MCPClient::Errors::TaskError, "Error listing tasks: #{e.message}"
|
|
648
|
-
end
|
|
649
|
-
end
|
|
650
|
-
|
|
651
|
-
# Cancel a task (tasks/cancel, MCP 2025-11-25)
|
|
652
|
-
# @param task_id [String, MCPClient::Task] the task to cancel; passing the
|
|
653
|
-
# Task handle returned by #call_tool_as_task routes to its own server
|
|
654
|
-
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
655
|
-
# @return [MCPClient::Task] the task with updated (cancelled) status
|
|
656
|
-
# @raise [ArgumentError] if the server is ambiguous in a multi-server client
|
|
657
|
-
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
658
|
-
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
659
|
-
# @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task)
|
|
660
|
-
def cancel_task(task_id, server: nil)
|
|
661
|
-
srv = select_task_server(task_id, server, 'cancel_task')
|
|
662
|
-
task_id = task_identifier(task_id)
|
|
663
|
-
ensure_task_capability!(srv, 'cancel')
|
|
664
|
-
|
|
665
|
-
begin
|
|
666
|
-
result = srv.rpc_request('tasks/cancel', { taskId: task_id })
|
|
667
|
-
MCPClient::Task.from_json(result, server: srv)
|
|
668
|
-
rescue MCPClient::Errors::ServerError => e
|
|
669
|
-
# A terminal task cannot be cancelled (-32602); that is an error, not a
|
|
670
|
-
# missing task, so keep it as a TaskError.
|
|
671
|
-
if e.message.match?(/terminal/i)
|
|
672
|
-
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"
|
|
673
677
|
end
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
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')
|
|
678
681
|
end
|
|
682
|
+
|
|
683
|
+
srv.listen(notifications: notifications, ack_timeout: ack_timeout, &listener)
|
|
679
684
|
end
|
|
680
685
|
|
|
681
686
|
# Set the logging level on all connected servers (MCP 2025-06-18)
|
|
682
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.
|
|
683
692
|
# @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
|
|
684
693
|
# 'critical', 'alert', 'emergency')
|
|
685
694
|
# @return [Array<Hash>] results from servers
|
|
686
695
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
687
696
|
def log_level=(level)
|
|
697
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
688
698
|
@servers.filter_map do |srv|
|
|
689
699
|
# MCP lifecycle: only use capabilities that were successfully
|
|
690
700
|
# negotiated — skip servers whose NEGOTIATED set lacks logging.
|
|
691
701
|
# Unconnected servers proceed: the transport-level gate re-checks
|
|
692
|
-
# after its handshake establishes the capability set.
|
|
693
|
-
|
|
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)
|
|
694
706
|
@logger.debug("Skipping logging/setLevel for #{srv.name || srv.class.name}: " \
|
|
695
707
|
'logging capability not negotiated')
|
|
696
708
|
next
|
|
@@ -702,85 +714,129 @@ module MCPClient
|
|
|
702
714
|
|
|
703
715
|
private
|
|
704
716
|
|
|
705
|
-
#
|
|
706
|
-
#
|
|
707
|
-
#
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
715
|
-
#
|
|
716
|
-
#
|
|
717
|
-
#
|
|
718
|
-
#
|
|
719
|
-
# @param
|
|
720
|
-
# @param
|
|
721
|
-
# @return [
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
#
|
|
729
|
-
#
|
|
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
|
|
730
770
|
end
|
|
731
771
|
end
|
|
772
|
+
end
|
|
732
773
|
|
|
733
|
-
|
|
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'
|
|
734
784
|
|
|
735
|
-
|
|
736
|
-
|
|
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) }
|
|
737
789
|
end
|
|
738
790
|
|
|
739
|
-
#
|
|
740
|
-
# @param server [MCPClient::ServerBase] the
|
|
741
|
-
# @param
|
|
742
|
-
# @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
|
|
743
795
|
# @return [void]
|
|
744
|
-
def
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
when
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
when 'notifications/tasks/status'
|
|
762
|
-
# MCP 2025-11-25: task status update (params are a flat Task)
|
|
763
|
-
handle_task_status_notification(server_id, params)
|
|
764
|
-
when 'notifications/cancelled'
|
|
765
|
-
# MCP 2025-11-25 cancellation utility: the server cancelled one of its
|
|
766
|
-
# own in-flight requests (sampling/elicitation). Server-request
|
|
767
|
-
# dispatch is synchronous per transport, so by the time this arrives
|
|
768
|
-
# the handler has usually completed; receivers MAY ignore
|
|
769
|
-
# cancellations they cannot honor — log for observability.
|
|
770
|
-
logger.debug("[#{server_id}] Server cancelled request #{params&.dig('requestId')}: " \
|
|
771
|
-
"#{params&.dig('reason') || 'no reason given'}")
|
|
772
|
-
when 'notifications/progress'
|
|
773
|
-
handle_progress_notification(server_id, params)
|
|
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, {}] }
|
|
774
813
|
else
|
|
775
|
-
|
|
776
|
-
|
|
814
|
+
raise ArgumentError, 'extensions must be an Array of identifiers or a Hash of identifier => settings, ' \
|
|
815
|
+
"got #{extensions.class}"
|
|
777
816
|
end
|
|
778
817
|
end
|
|
779
818
|
|
|
780
|
-
#
|
|
781
|
-
# @
|
|
782
|
-
|
|
783
|
-
|
|
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
|
+
|
|
784
840
|
# Route a notifications/progress message to the callback registered for
|
|
785
841
|
# its progressToken; unknown or stale tokens are debug-logged and dropped
|
|
786
842
|
# (MCP: "Senders and receivers SHOULD track active progress tokens").
|
|
@@ -841,7 +897,12 @@ module MCPClient
|
|
|
841
897
|
@progress_mutex.synchronize { @progress_callbacks.delete(token) }
|
|
842
898
|
end
|
|
843
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]
|
|
844
904
|
def handle_log_message(server_id, params)
|
|
905
|
+
MCPClient::Deprecations.warn(:logging, @logger)
|
|
845
906
|
level = params['level'] || 'info'
|
|
846
907
|
logger_name = params['logger']
|
|
847
908
|
data = params['data']
|
|
@@ -875,7 +936,7 @@ module MCPClient
|
|
|
875
936
|
# @param text [String] the peer-supplied text
|
|
876
937
|
# @return [String] sanitized, length-bounded text
|
|
877
938
|
def sanitize_peer_log_text(text)
|
|
878
|
-
escaped = text.gsub(/[
|
|
939
|
+
escaped = text.gsub(/[\x00-\x1F\x7F]/) { |c| format('\\x%02X', c.ord) }
|
|
879
940
|
return escaped if escaped.length <= MAX_PEER_LOG_MESSAGE_LENGTH
|
|
880
941
|
|
|
881
942
|
"#{escaped[0, MAX_PEER_LOG_MESSAGE_LENGTH]}... (truncated from #{escaped.length} chars)"
|
|
@@ -897,40 +958,6 @@ module MCPClient
|
|
|
897
958
|
end
|
|
898
959
|
end
|
|
899
960
|
|
|
900
|
-
# Resolve which server a task operation targets.
|
|
901
|
-
#
|
|
902
|
-
# Task IDs are only unique within the server that issued them, so silently
|
|
903
|
-
# defaulting to the first configured server can poll, read or cancel an
|
|
904
|
-
# unrelated task on the wrong server. Resolution order:
|
|
905
|
-
# 1. an explicit server: argument wins;
|
|
906
|
-
# 2. a Task handle carries the server that issued it;
|
|
907
|
-
# 3. a bare ID with exactly one configured server is unambiguous;
|
|
908
|
-
# 4. anything else is ambiguous and fails closed.
|
|
909
|
-
# @param task [String, MCPClient::Task] the task or its ID
|
|
910
|
-
# @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] explicit selector
|
|
911
|
-
# @param operation [String] calling method name, for the error message
|
|
912
|
-
# @return [MCPClient::ServerBase]
|
|
913
|
-
# @raise [ArgumentError] when the target server cannot be determined
|
|
914
|
-
def select_task_server(task, server_arg, operation)
|
|
915
|
-
# nil, not falsiness: `server: false` is an invalid selector that
|
|
916
|
-
# select_server rejects with ArgumentError, and treating it as "omitted"
|
|
917
|
-
# would silently route a read or a cancel somewhere instead of failing.
|
|
918
|
-
return select_server(server_arg) unless server_arg.nil?
|
|
919
|
-
return task.server if task.is_a?(MCPClient::Task) && task.server
|
|
920
|
-
return select_server(nil) if @servers.size <= 1
|
|
921
|
-
|
|
922
|
-
raise ArgumentError,
|
|
923
|
-
"#{operation} is ambiguous with multiple servers configured: task IDs are only unique per server. " \
|
|
924
|
-
'Pass the Task returned by call_tool_as_task, or name the server explicitly ' \
|
|
925
|
-
"(e.g. #{operation}(id, server: 'name'))."
|
|
926
|
-
end
|
|
927
|
-
|
|
928
|
-
# @param task [String, MCPClient::Task] a task or its ID
|
|
929
|
-
# @return [String] the task ID
|
|
930
|
-
def task_identifier(task)
|
|
931
|
-
task.is_a?(MCPClient::Task) ? task.task_id : task
|
|
932
|
-
end
|
|
933
|
-
|
|
934
961
|
# Select a server based on index, name, type, or instance
|
|
935
962
|
# @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
936
963
|
# @return [MCPClient::ServerBase]
|
|
@@ -963,26 +990,42 @@ module MCPClient
|
|
|
963
990
|
end
|
|
964
991
|
end
|
|
965
992
|
|
|
966
|
-
# 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.
|
|
967
997
|
# @param tool [MCPClient::Tool] tool definition with schema
|
|
968
998
|
# @param parameters [Hash] parameters to validate
|
|
969
|
-
# @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
|
|
970
1001
|
def validate_params!(tool, parameters)
|
|
971
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]
|
|
972
1013
|
return unless schema.is_a?(Hash)
|
|
973
1014
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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?
|
|
978
1021
|
|
|
979
|
-
missing = required
|
|
1022
|
+
missing = required - parameters.keys.map(&:to_s)
|
|
980
1023
|
|
|
981
1024
|
# Exclude required params that have a default value in the schema,
|
|
982
1025
|
# since the server will apply the default.
|
|
983
1026
|
missing = missing.reject do |param|
|
|
984
|
-
prop = properties[param]
|
|
985
|
-
prop.is_a?(Hash) &&
|
|
1027
|
+
prop = properties[param]
|
|
1028
|
+
prop.is_a?(Hash) && prop.key?('default')
|
|
986
1029
|
end
|
|
987
1030
|
|
|
988
1031
|
return unless missing.any?
|
|
@@ -990,12 +1033,19 @@ module MCPClient
|
|
|
990
1033
|
raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
|
|
991
1034
|
end
|
|
992
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
|
+
|
|
993
1042
|
# Validate a tools/call result's structuredContent against the tool's
|
|
994
1043
|
# declared outputSchema (MCP 2025-11-25 server/tools spec: "Clients SHOULD
|
|
995
1044
|
# validate structured results against this schema"; a tool declaring an
|
|
996
1045
|
# outputSchema must return structuredContent in successful results). Error
|
|
997
|
-
# results (isError: true)
|
|
998
|
-
#
|
|
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
|
|
999
1049
|
# keywords; the full 2020-12 vocabulary is out of scope (see
|
|
1000
1050
|
# MCPClient::SchemaValidator), and when the schema uses keywords outside
|
|
1001
1051
|
# that subset a partial-coverage warning is logged in both modes so :strict
|
|
@@ -1009,42 +1059,222 @@ module MCPClient
|
|
|
1009
1059
|
# is missing from a successful result or does not match the schema
|
|
1010
1060
|
def validate_structured_content!(tool, result)
|
|
1011
1061
|
return result unless tool.structured_output? && result.is_a?(Hash)
|
|
1012
|
-
return result if result['isError'] || result[:isError]
|
|
1013
|
-
|
|
1014
|
-
warn_partial_schema_coverage(tool)
|
|
1015
1062
|
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
|
1018
1096
|
handle_structured_content_violation(
|
|
1019
|
-
"Tool '#{tool.name}' declares an output schema but its successful result
|
|
1020
|
-
'(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)'
|
|
1021
1099
|
)
|
|
1022
1100
|
return result
|
|
1023
1101
|
end
|
|
1024
1102
|
|
|
1025
|
-
|
|
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)
|
|
1026
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.
|
|
1027
1109
|
handle_structured_content_violation(
|
|
1028
|
-
"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]}"
|
|
1029
1112
|
)
|
|
1030
1113
|
end
|
|
1031
1114
|
result
|
|
1032
1115
|
end
|
|
1033
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
|
+
|
|
1034
1233
|
# Warn (in both :warn and :strict modes) when a tool's output schema uses
|
|
1035
1234
|
# JSON Schema keywords the built-in validator cannot evaluate, so partial
|
|
1036
|
-
# 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.
|
|
1037
1237
|
# @param tool [MCPClient::Tool] the tool whose output schema is being used
|
|
1038
|
-
# @return [
|
|
1238
|
+
# @return [Array<String>] the unsupported keywords the schema uses
|
|
1039
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
|
+
|
|
1040
1245
|
unsupported = MCPClient::SchemaValidator.unsupported_keywords(tool.output_schema)
|
|
1041
|
-
|
|
1246
|
+
@output_schema_coverage[key] = { identity: tool_definition_identity(tool), unsupported: unsupported }
|
|
1247
|
+
return unsupported if unsupported.empty?
|
|
1042
1248
|
|
|
1043
1249
|
@logger.warn(
|
|
1044
|
-
"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 ' \
|
|
1045
1252
|
"keywords: #{unsupported.join(', ')} (full JSON Schema 2020-12 evaluation is not implemented, so " \
|
|
1046
1253
|
'conforming-looking data may still violate the schema)'
|
|
1047
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"
|
|
1048
1278
|
end
|
|
1049
1279
|
|
|
1050
1280
|
# Log a structured-content conformance violation and, in :strict mode,
|
|
@@ -1095,60 +1325,55 @@ module MCPClient
|
|
|
1095
1325
|
matching_tools.first
|
|
1096
1326
|
end
|
|
1097
1327
|
|
|
1098
|
-
#
|
|
1099
|
-
#
|
|
1100
|
-
#
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
# the tool is invoked as a plain call.
|
|
1108
|
-
return unless tool.task_required? && server_supports_task_tool_call?(tool.server)
|
|
1109
|
-
|
|
1110
|
-
raise MCPClient::Errors::ToolCallError,
|
|
1111
|
-
"Tool '#{tool_name}' requires task-augmented execution; call it with call_tool_as_task instead"
|
|
1112
|
-
end
|
|
1113
|
-
|
|
1114
|
-
# Whether a server advertised support for task-augmented tools/call, i.e.
|
|
1115
|
-
# capabilities.tasks.requests.tools.call.
|
|
1116
|
-
# @param srv [MCPClient::ServerBase] the server
|
|
1117
|
-
# @return [Boolean]
|
|
1118
|
-
def server_supports_task_tool_call?(srv)
|
|
1119
|
-
caps = srv.respond_to?(:capabilities) ? srv.capabilities : nil
|
|
1120
|
-
return false unless caps.is_a?(Hash)
|
|
1121
|
-
|
|
1122
|
-
tasks = caps['tasks'] || caps[:tasks]
|
|
1123
|
-
requests = tasks && (tasks['requests'] || tasks[:requests])
|
|
1124
|
-
tools = requests && (requests['tools'] || requests[:tools])
|
|
1125
|
-
call = tools && (tools['call'] || tools[:call])
|
|
1126
|
-
!call.nil?
|
|
1127
|
-
end
|
|
1128
|
-
|
|
1129
|
-
# Map a ServerError from a task operation to TaskNotFound or TaskError.
|
|
1130
|
-
# @param error [MCPClient::Errors::ServerError] the server error
|
|
1131
|
-
# @param task_id [String] the task id
|
|
1132
|
-
# @param action [String] a verb phrase for the error message (e.g. 'getting')
|
|
1133
|
-
# @return [MCPClient::Errors::TaskNotFound, MCPClient::Errors::TaskError]
|
|
1134
|
-
def task_error_from(error, task_id, action)
|
|
1135
|
-
if error.message.match?(/not found|unknown task|expired/i)
|
|
1136
|
-
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
|
|
1137
1337
|
end
|
|
1138
|
-
|
|
1139
|
-
MCPClient::Errors::TaskError.new("Error #{action} task '#{task_id}': #{error.message}")
|
|
1140
1338
|
end
|
|
1141
1339
|
|
|
1142
|
-
#
|
|
1143
|
-
#
|
|
1144
|
-
#
|
|
1145
|
-
#
|
|
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
|
|
1146
1366
|
# @return [void]
|
|
1147
|
-
def
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
|
1152
1377
|
end
|
|
1153
1378
|
|
|
1154
1379
|
# Generate a cache key for server-specific items
|
|
@@ -1220,15 +1445,18 @@ module MCPClient
|
|
|
1220
1445
|
# Supports both form mode (structured data) and URL mode (out-of-band interaction).
|
|
1221
1446
|
# @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
|
|
1222
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
|
|
1223
1451
|
# @return [Hash] the elicitation response
|
|
1224
|
-
def handle_elicitation_request(_request_id, params)
|
|
1452
|
+
def handle_elicitation_request(_request_id, params, server = nil)
|
|
1225
1453
|
mode = params['mode'] || 'form'
|
|
1226
1454
|
# MCP 2025-11-25: requests with a mode not declared in client
|
|
1227
1455
|
# capabilities MUST be rejected with -32602 (Invalid params). This check
|
|
1228
1456
|
# precedes everything else — an undeclared mode is -32602 even when no
|
|
1229
1457
|
# handler is configured.
|
|
1230
1458
|
unless SUPPORTED_ELICITATION_MODES.include?(mode)
|
|
1231
|
-
@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)}'")
|
|
1232
1460
|
return jsonrpc_error_result(-32_602, "Elicitation mode '#{mode}' is not supported")
|
|
1233
1461
|
end
|
|
1234
1462
|
|
|
@@ -1243,7 +1471,7 @@ module MCPClient
|
|
|
1243
1471
|
|
|
1244
1472
|
begin
|
|
1245
1473
|
result = if mode == 'url'
|
|
1246
|
-
handle_url_elicitation(params, message)
|
|
1474
|
+
handle_url_elicitation(params, message, server)
|
|
1247
1475
|
else
|
|
1248
1476
|
handle_form_elicitation(params, message)
|
|
1249
1477
|
end
|
|
@@ -1280,7 +1508,9 @@ module MCPClient
|
|
|
1280
1508
|
# Validate schema if present
|
|
1281
1509
|
if schema
|
|
1282
1510
|
schema_errors = ElicitationValidator.validate_schema(schema)
|
|
1283
|
-
|
|
1511
|
+
unless schema_errors.empty?
|
|
1512
|
+
@logger.warn("Elicitation schema validation warnings: #{sanitize_peer_log_text(schema_errors.join('; '))}")
|
|
1513
|
+
end
|
|
1284
1514
|
end
|
|
1285
1515
|
|
|
1286
1516
|
# Call the user-defined handler
|
|
@@ -1299,10 +1529,10 @@ module MCPClient
|
|
|
1299
1529
|
# Handle URL mode elicitation (MCP 2025-11-25)
|
|
1300
1530
|
# @param params [Hash] the elicitation parameters
|
|
1301
1531
|
# @param message [String] the human-readable message
|
|
1532
|
+
# @param server [MCPClient::ServerBase, nil] the server that asked
|
|
1302
1533
|
# @return [Object] handler result
|
|
1303
|
-
def handle_url_elicitation(params, message)
|
|
1304
|
-
|
|
1305
|
-
elicitation_id = params['elicitationId']
|
|
1534
|
+
def handle_url_elicitation(params, message, server = nil)
|
|
1535
|
+
url_params = url_elicitation_metadata(params, server)
|
|
1306
1536
|
|
|
1307
1537
|
# Call handler with URL-mode specific params
|
|
1308
1538
|
case @elicitation_handler.arity
|
|
@@ -1311,19 +1541,56 @@ module MCPClient
|
|
|
1311
1541
|
when 1
|
|
1312
1542
|
@elicitation_handler.call(message)
|
|
1313
1543
|
when 2, -1
|
|
1314
|
-
@elicitation_handler.call(message,
|
|
1544
|
+
@elicitation_handler.call(message, url_params)
|
|
1315
1545
|
else
|
|
1316
|
-
@elicitation_handler.call(message,
|
|
1317
|
-
params['metadata'])
|
|
1546
|
+
@elicitation_handler.call(message, url_params, params['metadata'])
|
|
1318
1547
|
end
|
|
1319
1548
|
end
|
|
1320
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
|
|
1580
|
+
end
|
|
1581
|
+
metadata
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1321
1584
|
# Format and validate the elicitation response
|
|
1322
1585
|
# @param result [Object] handler result
|
|
1323
1586
|
# @param params [Hash] original request params (for schema validation)
|
|
1324
1587
|
# @return [Hash] formatted response
|
|
1325
1588
|
def format_elicitation_response(result, params)
|
|
1326
|
-
response =
|
|
1589
|
+
response = if (params['mode'] || 'form') == 'url'
|
|
1590
|
+
normalize_url_elicitation_result(result)
|
|
1591
|
+
else
|
|
1592
|
+
normalize_elicitation_result(result)
|
|
1593
|
+
end
|
|
1327
1594
|
|
|
1328
1595
|
# Per the ElicitResult schema, content is only present when the action
|
|
1329
1596
|
# is accept and the mode was form; it is omitted for decline/cancel and
|
|
@@ -1348,6 +1615,29 @@ module MCPClient
|
|
|
1348
1615
|
response
|
|
1349
1616
|
end
|
|
1350
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
|
+
|
|
1351
1641
|
# Normalize a handler's return value into a string-keyed ElicitResult
|
|
1352
1642
|
# shape, so mixed or symbol keys cannot bypass content handling.
|
|
1353
1643
|
# @param result [Object] handler result
|
|
@@ -1384,14 +1674,21 @@ module MCPClient
|
|
|
1384
1674
|
ElicitationValidator.validate_content(response['content'], schema)
|
|
1385
1675
|
end
|
|
1386
1676
|
|
|
1387
|
-
# Ensure the action value conforms to MCP spec (accept, decline, cancel)
|
|
1388
|
-
#
|
|
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
|
|
1389
1685
|
def normalised_action_response(result)
|
|
1390
1686
|
action = result['action']
|
|
1391
1687
|
return result if %w[accept decline cancel].include?(action)
|
|
1392
1688
|
|
|
1393
|
-
@logger.warn("Unknown elicitation action '#{action}'
|
|
1394
|
-
|
|
1689
|
+
@logger.warn("Unknown elicitation action '#{sanitize_peer_log_text(action.to_s)}'; answering cancel " \
|
|
1690
|
+
'(consent is explicit)')
|
|
1691
|
+
result.merge('action' => 'cancel')
|
|
1395
1692
|
end
|
|
1396
1693
|
|
|
1397
1694
|
# Normalize roots array - convert Hashes to Root objects (MCP 2025-06-18)
|
|
@@ -1420,15 +1717,35 @@ module MCPClient
|
|
|
1420
1717
|
{ 'roots' => @roots.map(&:to_h) }
|
|
1421
1718
|
end
|
|
1422
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
|
+
|
|
1423
1744
|
# Send notification to all servers that roots have changed (MCP 2025-06-18)
|
|
1424
1745
|
# @return [void]
|
|
1425
1746
|
def notify_roots_changed
|
|
1426
1747
|
@servers.each do |server|
|
|
1427
|
-
|
|
1428
|
-
# MCP forbids using capabilities that were not negotiated, and
|
|
1429
|
-
# transports without a server-request channel (plain HTTP) never
|
|
1430
|
-
# declare roots.
|
|
1431
|
-
next unless server.respond_to?(:on_roots_list_request)
|
|
1748
|
+
next unless roots_list_changed_recipient?(server)
|
|
1432
1749
|
|
|
1433
1750
|
begin
|
|
1434
1751
|
server.rpc_notify('notifications/roots/list_changed', {})
|
|
@@ -1439,52 +1756,13 @@ module MCPClient
|
|
|
1439
1756
|
end
|
|
1440
1757
|
end
|
|
1441
1758
|
|
|
1442
|
-
#
|
|
1443
|
-
# @
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
def handle_sampling_request(_request_id, params)
|
|
1447
|
-
# Without a handler the sampling capability was never declared, so the
|
|
1448
|
-
# request targets an unsupported method: answer -32601 (Method not
|
|
1449
|
-
# found) rather than -1, which sampling.mdx § Error Handling reserves
|
|
1450
|
-
# for "User rejected sampling request".
|
|
1451
|
-
unless @sampling_handler
|
|
1452
|
-
@logger.warn('Received sampling request but no sampling handler is configured')
|
|
1453
|
-
return jsonrpc_error_result(-32_601, 'Sampling not supported: no sampling handler configured')
|
|
1454
|
-
end
|
|
1455
|
-
|
|
1456
|
-
# SEP-1577 (schema.ts CreateMessageRequestParams.tools/.toolChoice):
|
|
1457
|
-
# "The client MUST return an error if this field is provided but
|
|
1458
|
-
# ClientCapabilities.sampling.tools is not declared." -32602 is the
|
|
1459
|
-
# Invalid params code used by sampling.mdx § Error Handling.
|
|
1460
|
-
if (params.key?('tools') || params.key?('toolChoice')) && !@sampling_supports_tools
|
|
1461
|
-
@logger.warn('Rejecting tool-enabled sampling request: sampling.tools capability not declared')
|
|
1462
|
-
return jsonrpc_error_result(-32_602,
|
|
1463
|
-
'Invalid params: tools/toolChoice provided but the sampling.tools ' \
|
|
1464
|
-
'capability was not declared')
|
|
1465
|
-
end
|
|
1466
|
-
|
|
1467
|
-
messages = params['messages'] || []
|
|
1468
|
-
model_preferences = normalize_model_preferences(params['modelPreferences'])
|
|
1469
|
-
system_prompt = params['systemPrompt']
|
|
1470
|
-
max_tokens = params['maxTokens']
|
|
1471
|
-
|
|
1472
|
-
begin
|
|
1473
|
-
# Call the user-defined handler with parameters based on arity
|
|
1474
|
-
result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
|
|
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)
|
|
1475
1763
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
rescue StandardError => e
|
|
1479
|
-
@logger.error("Sampling handler error: #{e.message}")
|
|
1480
|
-
@logger.debug(e.backtrace.join("\n"))
|
|
1481
|
-
# A handler exception is an internal client failure (-32603), not a
|
|
1482
|
-
# user rejection: sampling.mdx § Error Handling reserves -1 for
|
|
1483
|
-
# "User rejected sampling request". The exception message itself is
|
|
1484
|
-
# host-internal (file paths, connection strings, library internals)
|
|
1485
|
-
# and stays in the local log rather than crossing to the server.
|
|
1486
|
-
jsonrpc_error_result(-32_603, 'Sampling error')
|
|
1487
|
-
end
|
|
1764
|
+
type = block['type'] || block[:type]
|
|
1765
|
+
type.is_a?(String) ? type : nil
|
|
1488
1766
|
end
|
|
1489
1767
|
|
|
1490
1768
|
# Call sampling handler with appropriate arity
|