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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -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
- # @!attribute [r] servers
15
- # @return [Array<MCPClient::ServerBase>] list of servers
16
- # @!attribute [r] tool_cache
17
- # @return [Hash<String, MCPClient::Tool>] cache of tools by composite key (server_id:name)
18
- # @!attribute [r] prompt_cache
19
- # @return [Hash<String, MCPClient::Prompt>] cache of prompts by composite key (server_id:name)
20
- # @!attribute [r] resource_cache
21
- # @return [Hash<String, MCPClient::Resource>] cache of resources by composite key (server_id:uri)
22
- # @!attribute [r] logger
23
- # @return [Logger] logger for client operations
24
- # @!attribute [r] roots
25
- # @return [Array<MCPClient::Root>] list of MCP roots (MCP 2025-06-18)
26
- attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots
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
- # @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25)
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
- # Host-provided Implementation info for the initialize clientInfo
104
- server.client_info = client_info if client_info && server.respond_to?(:client_info=)
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(&method(:handle_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
- return @prompt_cache.values if cache && !@prompt_cache.empty?
138
-
139
- prompts = []
140
- connection_errors = []
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
- prompts
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
- # If cursor is provided, we can only query one server (the one that provided the cursor)
218
- # This is a limitation of aggregating multiple servers
219
- if cursor
220
- # For now, just use the first server when cursor is provided
221
- # In a real implementation, you'd need to track which server the cursor came from
222
- return servers.first.list_resources(cursor: cursor) if servers.any?
223
-
224
- return { 'resources' => [], 'nextCursor' => nil }
225
- end
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
- resource_list.each do |resource|
238
- cache_key = cache_key_for(server, resource.uri)
239
- @resource_cache[cache_key] = resource
240
- resources << resource
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
- # Return hash format consistent with server methods
254
- { 'resources' => resources, 'nextCursor' => nil }
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
- return @tool_cache.values if cache && !@tool_cache.empty?
281
-
282
- tools = []
283
- connection_errors = []
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
- # If we didn't get any tools from any server but have servers configured, report failure
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
- result = begin
334
- server.call_tool(tool_name, parameters)
335
- rescue MCPClient::Errors::ConnectionError => e
336
- # Add server identity information to the error for better context
337
- server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
338
- raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message} (Server: #{server_id})"
339
- ensure
340
- # Tokens are only valid for the lifetime of the request: dropping the
341
- # registration filters out stale post-completion notifications.
342
- unregister_progress_callback(token) if token
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
- # Clear the cached tools so that next list_tools will fetch fresh data
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
- @tool_cache.clear
384
- @prompt_cache.clear
385
- @resource_cache.clear
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
- # Use the streaming API if it's available
461
- server.call_tool_streaming(tool_name, parameters)
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
- # Call a tool as a task (task-augmented tools/call, MCP 2025-11-25).
530
- #
531
- # Instead of blocking for the result, the server accepts the request and
532
- # immediately returns a task handle; the actual result is retrieved later
533
- # via {#get_task_result} once the task reaches a terminal status. The server
534
- # must advertise the tasks.requests.tools.call capability, and the tool must
535
- # declare execution.taskSupport of 'optional' or 'required'.
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
- # @return [MCPClient::Task] the task with current status
583
- # @raise [ArgumentError] if the server is ambiguous in a multi-server client
584
- # @raise [MCPClient::Errors::ServerNotFound] if no server is available
585
- # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
586
- # @raise [MCPClient::Errors::TaskError] if retrieving the task fails
587
- def get_task(task_id, server: nil)
588
- srv = select_task_server(task_id, server, 'get_task')
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
- ensure_task_capability!(srv, 'list')
639
-
640
- params = cursor ? { cursor: cursor } : {}
641
-
642
- begin
643
- result = srv.rpc_request('tasks/list', params) || {}
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
- raise task_error_from(e, task_id, 'cancelling')
676
- rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
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
- unless !capabilities_known?(srv) || srv.capability?('logging')
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
- # Whether the server's negotiated capability set is available yet.
706
- # @param srv [MCPClient::ServerBase] the server
707
- # @return [Boolean]
708
- def capabilities_known?(srv)
709
- srv.respond_to?(:capabilities) && !srv.capabilities.nil?
710
- end
711
-
712
- # Enforce the tasks.<operation> capability gate for a server (MCP
713
- # lifecycle: "Only use capabilities that were successfully negotiated").
714
- # When the negotiated capability set is not yet known, first trigger the
715
- # handshake with a cheap standard request (ping) and then re-apply the
716
- # gate against the freshly negotiated set, so a previously uninitialized
717
- # server that negotiates no tasks capability never receives the
718
- # prohibited request.
719
- # @param srv [MCPClient::ServerBase] the selected server
720
- # @param operation [String] the tasks sub-capability ('list' or 'cancel')
721
- # @return [void]
722
- # @raise [MCPClient::Errors::CapabilityError] if the negotiated set lacks the capability
723
- def ensure_task_capability!(srv, operation)
724
- if !capabilities_known?(srv) && srv.respond_to?(:ping)
725
- begin
726
- srv.ping
727
- rescue MCPClient::Errors::MCPError
728
- # Initialization failed; fall through and let the task request
729
- # itself surface the failure via the normal error path.
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
- return if !capabilities_known?(srv) || srv.capability?('tasks', operation)
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
- raise MCPClient::Errors::CapabilityError,
736
- "Server #{srv.name || srv.class.name} did not declare the tasks.#{operation} capability"
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
- # Process incoming JSON-RPC notifications with default handlers
740
- # @param server [MCPClient::ServerBase] the server that emitted the notification
741
- # @param method [String] JSON-RPC notification method
742
- # @param params [Hash] parameters for the notification
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 process_notification(server, method, params)
745
- server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
746
- case method
747
- when 'notifications/tools/list_changed'
748
- logger.warn("[#{server_id}] Tool list has changed, clearing tool cache")
749
- @tool_cache.clear
750
- when 'notifications/resources/updated'
751
- logger.warn("[#{server_id}] Resource #{params['uri']} updated")
752
- when 'notifications/prompts/list_changed'
753
- logger.warn("[#{server_id}] Prompt list has changed, clearing prompt cache")
754
- @prompt_cache.clear
755
- when 'notifications/resources/list_changed'
756
- logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
757
- @resource_cache.clear
758
- when 'notifications/message'
759
- # MCP 2025-06-18: Handle logging messages from server
760
- handle_log_message(server_id, params)
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
- # Log unknown notification types for debugging purposes
776
- logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
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
- # Handle logging message notification from server (MCP 2025-06-18)
781
- # @param server_id [String] server identifier for log prefix
782
- # @param params [Hash] log message params (level, logger, data)
783
- # @return [void]
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(/[-]/) { |c| format('\\x%02X', c.ord) }
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 properties)
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 missing
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
- required = schema['required'] || schema[:required]
975
- return unless required.is_a?(Array)
976
-
977
- properties = schema['properties'] || schema[:properties] || {}
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.map(&:to_s) - parameters.keys.map(&:to_s)
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] || properties[param.to_sym]
985
- prop.is_a?(Hash) && (prop.key?('default') || prop.key?(:default))
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) are exempt: the conformance requirements apply to
998
- # successful results only. Validation covers the common JSON Schema
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
- structured = result.key?('structuredContent') ? result['structuredContent'] : result[:structuredContent]
1017
- if structured.nil?
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 carries no structuredContent " \
1020
- '(required by the MCP 2025-11-25 tools spec)'
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
- errors = MCPClient::SchemaValidator.validate(structured, tool.output_schema)
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 schema: #{errors.join('; ')}"
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 [void]
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
- return if unsupported.empty?
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: schema uses unsupported " \
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
- # Reject a plain (synchronous) call for a tool whose execution.taskSupport is
1099
- # 'required'. A compliant server would reject a non-task-augmented tools/call
1100
- # for such a tool, so fail fast and point the caller at call_tool_as_task.
1101
- # @param tool [MCPClient::Tool] the resolved tool
1102
- # @param tool_name [String] the tool name (for the message)
1103
- # @raise [MCPClient::Errors::ToolCallError] if the tool requires task execution
1104
- def reject_task_required!(tool, tool_name)
1105
- # Tasks Tool-Level Negotiation rule 1: without tasks.requests.tools.call
1106
- # in the server capabilities, taskSupport is disregarded entirely and
1107
- # the tool is invoked as a plain call.
1108
- return unless tool.task_required? && server_supports_task_tool_call?(tool.server)
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
- # Handle a notifications/tasks/status notification (MCP 2025-11-25).
1143
- # The params are a flat Task.
1144
- # @param server_id [String] server identifier for the log prefix
1145
- # @param params [Hash] the flat task params
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 handle_task_status_notification(server_id, params)
1148
- task = MCPClient::Task.from_json(params)
1149
- logger.info("[#{server_id}] Task #{task.task_id} status: #{task.status}")
1150
- rescue StandardError => e
1151
- logger.debug("[#{server_id}] Failed to parse task status notification: #{e.message}")
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
- @logger.warn("Elicitation schema validation warnings: #{schema_errors.join('; ')}") unless schema_errors.empty?
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
- url = params['url']
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, { 'mode' => 'url', 'url' => url, 'elicitationId' => elicitation_id })
1544
+ @elicitation_handler.call(message, url_params)
1315
1545
  else
1316
- @elicitation_handler.call(message, { 'mode' => 'url', 'url' => url, 'elicitationId' => elicitation_id },
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 = normalize_elicitation_result(result)
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
- # Falls back to accept for unknown action values.
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}', defaulting to accept")
1394
- result.merge('action' => 'accept')
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
- # Only notify sessions where the roots capability could be declared:
1428
- # MCP forbids using capabilities that were not negotiated, and
1429
- # transports without a server-request channel (plain HTTP) never
1430
- # declare roots.
1431
- next unless server.respond_to?(:on_roots_list_request)
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
- # Handle sampling/createMessage request from server (MCP 2025-11-25)
1443
- # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
1444
- # @param params [Hash] the sampling parameters
1445
- # @return [Hash] the sampling response (role, content, model, stopReason)
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
- # Validate and format response
1477
- validate_sampling_response(result)
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