ruby-mcp-client 2.0.0 → 3.0.0

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