ruby-mcp-client 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -1,15 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
5
+ require 'json'
6
+ require 'zlib'
7
+ require 'stringio'
8
+ require_relative 'deep_copy'
9
+ require_relative 'deprecation_notices'
10
+ require_relative 'header_params'
11
+ require_relative 'json_rpc_common/envelopes'
12
+ require_relative 'json_rpc_common/error_bodies'
13
+ require_relative 'json_rpc_common/input_waits'
14
+ require_relative 'subscription_support'
15
+ require_relative 'input_round_trips'
16
+ require_relative 'result_caching'
17
+ require_relative 'request_metadata'
18
+ require_relative 'round_trip_marker'
19
+ require_relative 'result_completeness'
20
+ require_relative 'session_pin'
21
+
3
22
  module MCPClient
4
23
  # Shared retry/backoff logic for JSON-RPC transports
5
24
  module JsonRpcCommon
25
+ include Envelopes
26
+ include ErrorBodies
27
+ include InputWaits
28
+ include RoundTripMarker
29
+ include ResultCompleteness
30
+ include DeprecationNotices
31
+ include SubscriptionSupport
32
+ include InputRoundTrips
33
+ include ResultCaching
34
+ # The `_meta` a request carries, the fingerprint a cached result is bound
35
+ # to, and the evaluation a cache decision holds for the request it leads to.
36
+ include RequestMetadata
37
+ # Requests may be pinned to the session they belong to (see SessionPin).
38
+ include SessionPin
39
+
40
+ # Input requests of a multi-round tool call (see InputRoundTrips).
41
+
6
42
  # JSON-RPC methods with arbitrary side effects that MUST NOT be re-sent
7
43
  # automatically. Even a "transient" failure (5xx, dropped connection,
8
44
  # malformed response) can arrive AFTER the server received the request,
9
45
  # so a retry could execute the operation twice — and JSON-RPC has no
10
46
  # idempotency key to make the duplicate safe. Callers who want to retry
11
47
  # such an operation must decide that explicitly.
12
- NON_IDEMPOTENT_METHODS = %w[tools/call].freeze
48
+ # tasks/update (MCP 2026-07-28 tasks extension) delivers one-shot input
49
+ # responses: a replay could advance a task twice.
50
+ NON_IDEMPOTENT_METHODS = %w[tools/call tasks/update].freeze
13
51
 
14
52
  # Execute the block with retry/backoff for transient errors only.
15
53
  #
@@ -43,6 +81,12 @@ module MCPClient
43
81
  # side effect (and re-does the oversized decode).
44
82
  raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)
45
83
  raise if e.is_a?(MCPClient::Errors::ResponseTooLargeError)
84
+ # A broken response stream is already handled where it is raised: the
85
+ # transport issues the one replacement request MCP 2026-07-28 calls
86
+ # for and this error means that replacement was lost too. Retrying
87
+ # here would silently turn "re-issue once" into retries + 1 rounds of
88
+ # two attempts each.
89
+ raise if e.is_a?(MCPClient::Errors::ResponseStreamClosedError)
46
90
 
47
91
  if NON_IDEMPOTENT_METHODS.include?(method)
48
92
  @logger.debug("Not retrying non-idempotent #{method} after error: #{e.message}")
@@ -60,6 +104,40 @@ module MCPClient
60
104
  end
61
105
  end
62
106
 
107
+ # Maximum characters of peer-supplied text written to the host log.
108
+ MAX_PEER_LOG_TEXT_LENGTH = 4096
109
+
110
+ # Make peer-supplied text safe to write to the host log: control
111
+ # characters (notably newlines, which would let a server forge log
112
+ # entries) are escaped and the result is capped.
113
+ # @param text [Object] peer-supplied text
114
+ # @return [String] sanitized, length-bounded text
115
+ def sanitize_log_text(text)
116
+ escaped = text.to_s.gsub(/[\x00-\x1F\x7F]/) { |c| format('\\x%02X', c.ord) }
117
+ return escaped if escaped.length <= MAX_PEER_LOG_TEXT_LENGTH
118
+
119
+ "#{escaped[0, MAX_PEER_LOG_TEXT_LENGTH]}... (truncated from #{escaped.length} chars)"
120
+ end
121
+
122
+ # Tell a host layered above this transport to drop the caches a
123
+ # notification invalidates, surviving whatever it does with it.
124
+ #
125
+ # Called by every path that fans a notification out to the host — the
126
+ # subscription routing on stdio and both HTTP transports, the legacy SSE
127
+ # parser, and the synthetic tools/list_changed a HeaderMismatch refresh
128
+ # announces — and always before the notification reaches a subscription's
129
+ # listeners. See {MCPClient::ServerBase#on_cache_invalidation} for why the
130
+ # host's own callback cannot serve.
131
+ # @param method [String] notification method
132
+ # @param params [Hash, nil] notification params
133
+ # @return [void]
134
+ def notify_cache_invalidation(method, params)
135
+ @cache_invalidation_callback&.call(method, params)
136
+ rescue StandardError => e
137
+ @logger.warn("Cache invalidation callback error for #{sanitize_log_text(method)}: " \
138
+ "#{sanitize_log_text(e.message)}")
139
+ end
140
+
63
141
  # A log-safe description of a JSON-RPC message: its method and id only.
64
142
  #
65
143
  # Params and results are deliberately omitted. tools/call arguments and
@@ -99,10 +177,13 @@ module MCPClient
99
177
  end
100
178
 
101
179
  # A log-safe description of a payload body: its size, never its content.
102
- # @param body [String, nil] the response/request body
180
+ # A host's `conn.response :json` middleware hands the decoded object here
181
+ # instead of the bytes it came from; say so rather than measuring it.
182
+ # @param body [String, Object, nil] the response/request body
103
183
  # @return [String]
104
184
  def describe_body_size(body)
105
- return 'empty body' if body.nil? || body.empty?
185
+ return 'empty body' if body.nil? || (body.respond_to?(:empty?) && body.empty?)
186
+ return "decoded #{body.class} body" unless body.is_a?(String)
106
187
 
107
188
  "#{body.bytesize} bytes"
108
189
  end
@@ -156,38 +237,469 @@ module MCPClient
156
237
  params
157
238
  end
158
239
 
240
+ # Transports that derive `Mcp-Param-*` headers from their tool list run a
241
+ # call inside a slot of its own for the definition it goes out under
242
+ # ({MCPClient::CalledToolDefinition}); the others have nothing to record
243
+ # and the call runs as it is.
244
+ # @yield the call
245
+ # @return [Object] the block value
246
+ def recording_called_tool_definition
247
+ yield
248
+ end
249
+ private :recording_called_tool_definition
250
+
251
+ # @see MCPClient::CalledToolDefinition#outside_called_tool_definition
252
+ # @yield the host code
253
+ # @return [Object] the block value
254
+ def outside_called_tool_definition
255
+ yield
256
+ end
257
+ private :outside_called_tool_definition
258
+
259
+ # Which claim a message being built makes on the evaluation the open
260
+ # operation reserved (see {MCPClient::RequestMetadata::HeldRequestMeta}).
261
+ #
262
+ # A probe is never sent: it models the reserved request, so it reads that
263
+ # request's evaluation without spending it. A real request spends the
264
+ # reservation only when it *is* the request the reservation was made for
265
+ # -- the one the operation holding it sends. Everything else -- a
266
+ # reconnect's handshake, a re-opened `subscriptions/listen`, a
267
+ # cancellation, and everything host code issues from behind the boundary
268
+ # a transport crosses to reach it ({MCPClient::RequestMetadata#outside_request_meta_hold}),
269
+ # raw `rpc_request` of the very same method included -- reads the host
270
+ # afresh and leaves the reservation for the request that holds it.
271
+ # @param method [String] the JSON-RPC method being built
272
+ # @param note [Boolean] whether the message is really going out
273
+ # @return [Symbol] :spend, :model or :none
274
+ def request_meta_claim(method, note)
275
+ return :model unless note
276
+
277
+ held = claimable_request_meta_hold
278
+ held && held.request_method == method ? :spend : :none
279
+ end
280
+
159
281
  # Build a JSON-RPC request object
160
282
  # @param method [String] JSON-RPC method name
161
283
  # @param params [Hash] parameters for the request
162
284
  # @param id [Integer] request ID
285
+ # @param note [Boolean] whether this request's effective parameters are
286
+ # remembered as this thread's current request (a probe that is never
287
+ # sent passes false)
163
288
  # @return [Hash] the JSON-RPC request object
164
- def build_jsonrpc_request(method, params, id)
289
+ def build_jsonrpc_request(method, params, id, note: true)
290
+ effective = with_request_meta(params, claim: request_meta_claim(method, note))
291
+ note_request_params(effective) if note
165
292
  {
166
293
  'jsonrpc' => '2.0',
167
294
  'id' => id,
168
295
  'method' => method,
169
- 'params' => params
296
+ 'params' => effective
170
297
  }
171
298
  end
172
299
 
300
+ # Log levels defined by the logging utility (RFC 5424 severities).
301
+ LOG_LEVELS = %w[debug info notice warning error critical alert emergency].freeze
302
+
303
+ # Extension identifiers follow the `_meta` key naming rules with a
304
+ # mandatory prefix (basic/versioning "Extension Negotiation"): dotted
305
+ # labels, a slash, then a name. The name is optional — basic/index says
306
+ # of it "Unless empty, MUST begin and end with an alphanumeric
307
+ # character" — so a prefix on its own (`com.example/`) is a valid
308
+ # identifier.
309
+ EXTENSION_ID_PATTERN = %r{\A(?:[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)*[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?/
310
+ (?:[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)?\z}x
311
+
312
+ # The server's established protocol era.
313
+ #
314
+ # Deliberately not the same question as {#modern?}: while a
315
+ # server/discover probe is in flight, protocol_version holds the version
316
+ # the probe *proposes*, which is what outgoing requests must declare but
317
+ # says nothing about what the server speaks. Anything that reacts to the
318
+ # peer — above all, whether a server-initiated request is prohibited —
319
+ # must consult the era, not the tentative outgoing version.
320
+ # @return [Symbol, nil] :modern, :legacy, or nil before the era is known
321
+ def protocol_era
322
+ return nil if era_probe_in_flight? || protocol_version.nil?
323
+
324
+ modern? ? :modern : :legacy
325
+ end
326
+
327
+ # Begin proposing a protocol version that the server has not confirmed:
328
+ # until the probe settles, the era is unknown.
329
+ # @return [void]
330
+ def begin_era_probe
331
+ @era_probe_in_flight = true
332
+ end
333
+
334
+ # The probe has been answered (or given up on): the era is now whatever
335
+ # protocol_version says.
336
+ # @return [void]
337
+ def settle_era_probe
338
+ @era_probe_in_flight = false
339
+ end
340
+
341
+ # @return [Boolean] whether protocol_version is only a proposal so far
342
+ def era_probe_in_flight?
343
+ defined?(@era_probe_in_flight) ? @era_probe_in_flight : false
344
+ end
345
+
346
+ # Protocol versions a modern server advertised in its DiscoverResult.
347
+ # @return [Array<String>, nil]
348
+ def supported_versions
349
+ defined?(@supported_versions) ? @supported_versions : nil
350
+ end
351
+
352
+ # Pick the newest modern version this client speaks from a server's
353
+ # advertised list (DiscoverResult.supportedVersions or
354
+ # UnsupportedProtocolVersionError.data.supported).
355
+ # @param supported [Array<String>, nil] versions the server supports
356
+ # @return [String, nil] the chosen version, nil when none is mutual
357
+ def select_protocol_version(supported)
358
+ return nil unless supported.is_a?(Array)
359
+
360
+ MCPClient::MODERN_PROTOCOL_VERSIONS.find { |version| supported.include?(version) }
361
+ end
362
+
363
+ # Host-supplied metadata merged into every request's `_meta`: a Hash, or
364
+ # a callable returning one, evaluated per request. Intended for
365
+ # OpenTelemetry trace context (`traceparent`, `tracestate`, `baggage`)
366
+ # and vendor-prefixed keys. Reserved protocol fields cannot be
367
+ # overridden through it.
368
+ # @return [Hash, #call, nil]
369
+ attr_accessor :request_meta
370
+
371
+ # Whether to identify this client on every request via
372
+ # `io.modelcontextprotocol/clientInfo` (MCP 2026-07-28: clients SHOULD,
373
+ # "unless specifically configured not to do so").
374
+ # @param value [Boolean]
375
+ attr_writer :send_client_info
376
+
377
+ # @return [Boolean] whether clientInfo is sent (default true)
378
+ def send_client_info?
379
+ !(defined?(@send_client_info) && @send_client_info == false)
380
+ end
381
+
382
+ # Declare support for an MCP extension (basic/versioning "Extension
383
+ # Negotiation"): advertised under `clientCapabilities.extensions` on
384
+ # every modern request.
385
+ # @param identifier [String] the extension id, e.g. 'io.modelcontextprotocol/tasks'
386
+ # @param settings [Hash] per-extension settings ({} = support, no settings)
387
+ # @return [void]
388
+ # @raise [ArgumentError] if the identifier lacks the mandatory prefix
389
+ def declare_extension(identifier, settings = {})
390
+ unless identifier.is_a?(String) && identifier.match?(EXTENSION_ID_PATTERN)
391
+ raise ArgumentError, "Extension identifier #{identifier.inspect} must have a dotted prefix and a slash " \
392
+ '(e.g. io.modelcontextprotocol/tasks)'
393
+ end
394
+
395
+ settings = {} if settings.nil?
396
+ unless settings.is_a?(Hash)
397
+ raise ArgumentError, "Extension settings for #{identifier} must be an object (Hash), got #{settings.class}"
398
+ end
399
+
400
+ # An extension that adds a result type is advertised only by a client
401
+ # that can accept that result type: a server told the extension is
402
+ # negotiated may answer with it, and an unrecognized resultType is an
403
+ # invalid response — the usable answer would be lost.
404
+ added = RESULT_TYPE_EXTENSIONS[identifier]
405
+ if added && !implemented_extension_result_types.key?(identifier)
406
+ raise ArgumentError,
407
+ "Extension #{identifier} adds the result type #{added.inspect}, which this client does not implement"
408
+ end
409
+
410
+ @declared_extensions ||= {}
411
+ @declared_extensions[identifier] = settings
412
+ end
413
+
414
+ # @return [Hash] declared extension id => settings
415
+ def declared_extensions
416
+ defined?(@declared_extensions) && @declared_extensions ? @declared_extensions : {}
417
+ end
418
+
419
+ # Attach request-level `_meta` to a params object: the host's
420
+ # request_meta defaults first, then any per-request `_meta` the caller
421
+ # supplied (which wins over the defaults), then — for a modern server —
422
+ # the reserved protocol fields, which always win. Params are returned
423
+ # untouched when there is nothing to add, so legacy traffic is unchanged.
424
+ # @param params [Hash, nil] request params (String or Symbol keys)
425
+ # @return [Hash, nil] params with `_meta` merged under the String key
426
+ def with_request_meta(params, claim: :none)
427
+ params = merge_meta_spellings(params)
428
+ defaults = host_request_meta(claim)
429
+ if defaults.empty? && !modern? && !reserved_meta_supplied?(params)
430
+ # Legacy traffic is passed through untouched — a `_meta` the caller
431
+ # supplied goes out as it stands, unless it names a transport-owned key.
432
+ warn_request_log_level_deprecated(params.is_a?(Hash) ? (params['_meta'] || params[:_meta]) : nil)
433
+ return params
434
+ end
435
+
436
+ params = params.is_a?(Hash) ? params.dup : {}
437
+ supplied = params.delete('_meta')
438
+ supplied = supplied.is_a?(Hash) ? supplied.transform_keys(&:to_s) : {}
439
+ # The reserved protocol fields are transport-owned in per-call `_meta`
440
+ # exactly as they are in request_meta. Merging the transport's own
441
+ # values over the caller's is not enough: a field the transport omits
442
+ # (clientInfo, once the host set send_client_info = false) has nothing
443
+ # to overwrite the caller's value with, so it would be transmitted
444
+ # anyway. Drop them before the defaults are merged.
445
+ supplied = supplied.except(*PROTECTED_META_KEYS)
446
+
447
+ meta = defaults.merge(supplied)
448
+ if modern?
449
+ meta[META_LOG_LEVEL] = @log_level if defined?(@log_level) && @log_level && !meta.key?(META_LOG_LEVEL)
450
+ meta.merge!(required_request_meta)
451
+ end
452
+ # The request carries a copy, never the host's own objects. `request_meta`
453
+ # is read from whatever the host keeps -- a string it may rewrite in
454
+ # place, a container it may add to -- and a merge is shallow, so a
455
+ # request built from it would otherwise go on changing after it was
456
+ # built: the body one fingerprint describes is not the body the next
457
+ # one does, and neither need be the body that was sent (MCP 2026-07-28
458
+ # server/utilities/caching: a result is bound to the parameters of the
459
+ # request that produced it).
460
+ params['_meta'] = MCPClient::DeepCopy.copy(meta)
461
+ warn_request_log_level_deprecated(meta)
462
+ params
463
+ end
464
+
465
+ # A caller's `_meta` supplied under the Symbol key, or under both
466
+ # spellings, becomes one String-keyed `_meta` (the String one winning on
467
+ # a clash). Two spellings would otherwise serialize as two `_meta`
468
+ # members — and whatever was stripped from one copy would reach the wire
469
+ # through the other, since only one is inspected.
470
+ # @param params [Hash, nil] request params
471
+ # @return [Hash, nil] params with at most one `_meta` member, under the String key
472
+ def merge_meta_spellings(params)
473
+ return params unless params.is_a?(Hash) && params.key?(:_meta)
474
+
475
+ params = params.dup
476
+ symbol_meta = params.delete(:_meta)
477
+ string_meta = params['_meta']
478
+ symbol_meta = symbol_meta.is_a?(Hash) ? symbol_meta.transform_keys(&:to_s) : {}
479
+ string_meta = string_meta.is_a?(Hash) ? string_meta.transform_keys(&:to_s) : {}
480
+ params['_meta'] = symbol_meta.merge(string_meta)
481
+ params
482
+ end
483
+
484
+ # Whether a caller's params carry a `_meta` key the transport owns.
485
+ #
486
+ # A legacy request with no host defaults has nothing to merge and no
487
+ # protocol fields to add, so it is otherwise handed on untouched — but the
488
+ # reserved keys are the client's to set in every era. A dual-era server
489
+ # reads a request carrying modern per-request `_meta` AS a modern request
490
+ # (basic/versioning), so leaving a caller's copy on the wire would have
491
+ # one call served statelessly while this session goes on believing it
492
+ # negotiated 2025-11-25.
493
+ # @param params [Hash, nil] request params
494
+ # @return [Boolean]
495
+ def reserved_meta_supplied?(params)
496
+ return false unless params.is_a?(Hash)
497
+
498
+ supplied = params['_meta'] || params[:_meta]
499
+ return false unless supplied.is_a?(Hash)
500
+
501
+ supplied.any? { |key, _| PROTECTED_META_KEYS.include?(key.to_s) }
502
+ end
503
+
504
+ # The reserved per-request protocol fields for a modern server
505
+ # (basic/index "Per-request protocol fields").
506
+ # @return [Hash]
507
+ def required_request_meta
508
+ meta = { META_PROTOCOL_VERSION => protocol_version }
509
+ meta[META_CLIENT_INFO] = client_info_payload if send_client_info?
510
+ meta[META_CLIENT_CAPABILITIES] = client_capabilities
511
+ meta
512
+ end
513
+
514
+ # The host's request_meta for one message, with any reserved protocol
515
+ # keys it tries to set dropped.
516
+ #
517
+ # A message that claims the open operation's reservation reads the
518
+ # evaluation held for it (making it, the first time, and holding it);
519
+ # `:spend` marks it spent, so the request it was held for carries it and
520
+ # nothing else ever does. `:none` reads the host afresh and leaves the
521
+ # reservation alone -- a host callable that vends a one-time value is
522
+ # never spent twice, and never on the wrong request.
523
+ # @param claim [Symbol] :spend, :model or :none
524
+ # @return [Hash] String-keyed metadata (possibly empty)
525
+ def host_request_meta(claim = :none)
526
+ held = claim == :none ? nil : claimable_request_meta_hold
527
+ return spend_held_request_meta(held, claim) if held&.evaluated
528
+
529
+ source = request_meta
530
+ source = source.call if source.respond_to?(:call)
531
+ meta = source.is_a?(Hash) ? source.transform_keys(&:to_s).except(*PROTECTED_META_KEYS) : {}
532
+ return meta unless held
533
+
534
+ held.evaluated = true
535
+ held.value = meta
536
+ spend_held_request_meta(held, claim)
537
+ end
538
+
539
+ # @param held [MCPClient::RequestMetadata::HeldRequestMeta]
540
+ # @param claim [Symbol]
541
+ # @return [Hash] the held evaluation
542
+ def spend_held_request_meta(held, claim)
543
+ held.spent = true if claim == :spend
544
+ held.value
545
+ end
546
+
547
+ # Apply a DiscoverResult (server/discover): choose the protocol version
548
+ # for subsequent requests and record the server's capabilities,
549
+ # identity and instructions.
550
+ # @param result [Hash] the DiscoverResult
551
+ # @return [Hash] the result
552
+ # @raise [MCPClient::Errors::ConnectionError] if the result is malformed or no version is mutual
553
+ def apply_discover_result(result)
554
+ unless result.is_a?(Hash)
555
+ raise MCPClient::Errors::ConnectionError, "Server returned an invalid server/discover result (#{result.class})"
556
+ end
557
+
558
+ reject_input_required_discover!(result)
559
+ reject_task_result_discover!(result)
560
+ versions = result['supportedVersions']
561
+ unless versions.is_a?(Array) && versions.all?(String)
562
+ raise MCPClient::Errors::ConnectionError, 'server/discover result has no supportedVersions list'
563
+ end
564
+
565
+ version = select_protocol_version(versions)
566
+ unless version
567
+ # A DiscoverResult settles the era even when it settles no version:
568
+ # only a modern server answers server/discover with one. Raising the
569
+ # typed error keeps MCPClient.connect from trying the legacy
570
+ # transports, which cannot do better against a modern server.
571
+ raise MCPClient::Errors::ModernServerError,
572
+ "Server supports protocol versions #{versions.join(', ')}, none of which this client speaks " \
573
+ "(modern versions supported: #{MCPClient::MODERN_PROTOCOL_VERSIONS.join(', ')})"
574
+ end
575
+ # Everything is checked before anything is recorded: a refresh that
576
+ # fails to validate changes nothing, not even the identity it carried.
577
+ capabilities = result['capabilities']
578
+ unless capabilities.nil? || capabilities.is_a?(Hash)
579
+ raise MCPClient::Errors::ConnectionError, 'server/discover result capabilities is not an object'
580
+ end
581
+
582
+ meta = result['_meta']
583
+ unless meta.nil? || meta.is_a?(Hash)
584
+ raise MCPClient::Errors::ConnectionError, 'server/discover result _meta is not an object'
585
+ end
586
+
587
+ @protocol_version = version
588
+ @supported_versions = versions
589
+ @last_discover_result = result
590
+ entry = record_cache_hint(:discover, result)
591
+ @capabilities = capabilities || {}
592
+ @instructions = result['instructions']
593
+ info = meta && meta[META_SERVER_INFO]
594
+ @server_info = info if info.is_a?(Hash)
595
+ record_discovery_freshness(result, entry)
596
+ result
597
+ end
598
+
599
+ # Record a DiscoverResult's cache hints (CacheableResult: ttlMs,
600
+ # cacheScope). A ttlMs of zero means the result is immediately stale.
601
+ # @param result [Hash] the DiscoverResult
602
+ # @param entry [MCPClient::CachedResult, nil] the cache entry this result was recorded as
603
+ # @return [void]
604
+ def record_discovery_freshness(result, entry = nil)
605
+ # One reading of ttlMs for every cached result, on one clock, so
606
+ # cache_info(:discover) and this decision never disagree: a JSON number
607
+ # of milliseconds; zero is immediately stale, and a negative, absent or
608
+ # malformed hint is treated as zero (a DiscoverResult without a hint is
609
+ # re-read on the next access that needs it).
610
+ #
611
+ # It runs from the RECEIPT of the response, which is what the entry was
612
+ # dated by ("fresh for that many milliseconds" after it arrived), not
613
+ # from this moment: everything between the response arriving and the
614
+ # client getting round to applying it — a notification delivered off
615
+ # the same stream, host middleware, a slow parse — is time the result
616
+ # has already spent, not time it is owed.
617
+ received = entry&.received_at || discovery_clock
618
+ @discovery_expires_at = received + (MCPClient::CachedResult.normalize_ttl(result['ttlMs']) / 1000.0)
619
+ scope = result['cacheScope']
620
+ @discovery_cache_scope = scope.is_a?(String) ? scope : nil
621
+ end
622
+
623
+ # @return [Float] the monotonic clock, in seconds, discovery freshness is
624
+ # judged by: the transport's own when it has one (the one its cache
625
+ # entries are dated by), the process clock otherwise
626
+ def discovery_clock
627
+ return monotonic_now if respond_to?(:monotonic_now, true)
628
+
629
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
630
+ end
631
+
632
+ # Whether the last DiscoverResult is still fresh by its own ttlMs. A
633
+ # session that recorded no discovery at all (a 2025-11-25 one) has
634
+ # nothing to refresh.
635
+ # @return [Boolean]
636
+ def discovery_fresh?
637
+ deadline = defined?(@discovery_expires_at) ? @discovery_expires_at : nil
638
+ deadline.nil? || discovery_clock < deadline
639
+ end
640
+
641
+ # @return [String, nil] the cacheScope the last DiscoverResult declared
642
+ def discovery_cache_scope
643
+ defined?(@discovery_cache_scope) ? @discovery_cache_scope : nil
644
+ end
645
+
646
+ # Validate a log level name (logging utility levels).
647
+ # @param level [String, Symbol] the level
648
+ # @return [String] the normalized level
649
+ # @raise [ArgumentError] if it is not a defined level
650
+ def validate_log_level!(level)
651
+ name = level.to_s
652
+ return name if LOG_LEVELS.include?(name)
653
+
654
+ raise ArgumentError, "Unknown log level #{level.inspect}; expected one of #{LOG_LEVELS.join(', ')}"
655
+ end
656
+
173
657
  # Build a JSON-RPC notification object (no response expected)
174
658
  # @param method [String] JSON-RPC method name
175
659
  # @param params [Hash] parameters for the notification
176
660
  # @return [Hash] the JSON-RPC notification object
177
661
  def build_jsonrpc_notification(method, params)
662
+ # A notification is never the request a cache decision was made for: it
663
+ # reads the host afresh and leaves the reservation for that request.
664
+ effective = with_request_meta(params, claim: :none)
178
665
  {
179
666
  'jsonrpc' => '2.0',
180
667
  'method' => method,
181
- 'params' => params
668
+ # Modern notifications carry the same _meta as requests: on HTTP the
669
+ # MCP-Protocol-Version header must match the body.
670
+ 'params' => effective
182
671
  }
183
672
  end
184
673
 
674
+ # The protocol version in use with this server, once established: chosen
675
+ # via server/discover for a modern server or negotiated by initialize for
676
+ # a legacy one. nil until then.
677
+ # @return [String, nil]
678
+ def protocol_version
679
+ defined?(@protocol_version) ? @protocol_version : nil
680
+ end
681
+
682
+ # Whether this server speaks a modern (per-request metadata, no
683
+ # handshake) protocol revision (MCP 2026-07-28 basic/versioning
684
+ # "Terminology"). false until the era is established.
685
+ # @return [Boolean]
686
+ def modern?
687
+ MCPClient::MODERN_PROTOCOL_VERSIONS.include?(protocol_version)
688
+ end
689
+
185
690
  # Generate initialization parameters for MCP protocol
186
691
  # @return [Hash] the initialization parameters
187
692
  def initialization_params
693
+ # Extension negotiation is a 2026-07-28 mechanism (basic/versioning
694
+ # "Extension Negotiation", carried in every modern request's _meta):
695
+ # the 2025-11-25 handshake has no such capability, and an extension
696
+ # defined for 2026-07-28 (the tasks extension, say) is not advertised
697
+ # to a server that negotiates the legacy protocol.
698
+ capabilities = client_capabilities
699
+ capabilities.delete('extensions')
188
700
  {
189
701
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
190
- 'capabilities' => client_capabilities,
702
+ 'capabilities' => capabilities,
191
703
  'clientInfo' => client_info_payload
192
704
  }
193
705
  end
@@ -202,7 +714,9 @@ module MCPClient
202
714
  # @raise [MCPClient::Errors::ConnectionError] if the version is unsupported
203
715
  def validate_protocol_version!(result)
204
716
  version = result['protocolVersion']
205
- return version if MCPClient::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
717
+ # Only handshake-based revisions are valid here: a server answering
718
+ # initialize with a modern (per-request metadata) version is confused.
719
+ return version if MCPClient::LEGACY_PROTOCOL_VERSIONS.include?(version)
206
720
 
207
721
  begin
208
722
  cleanup if respond_to?(:cleanup)
@@ -211,7 +725,7 @@ module MCPClient
211
725
  end
212
726
  raise MCPClient::Errors::ConnectionError,
213
727
  "Server negotiated unsupported protocol version #{version.inspect} " \
214
- "(supported: #{MCPClient::SUPPORTED_PROTOCOL_VERSIONS.join(', ')}); disconnecting"
728
+ "(supported: #{MCPClient::LEGACY_PROTOCOL_VERSIONS.join(', ')}); disconnecting"
215
729
  end
216
730
 
217
731
  # The Implementation object sent as clientInfo: the host-provided info
@@ -232,17 +746,27 @@ module MCPClient
232
746
  # @return [Hash] the capabilities object for the initialize request
233
747
  def client_capabilities
234
748
  capabilities = {}
749
+ # On a modern server these features are served through the multi
750
+ # round-trip pattern (InputRequiredResult), on a legacy one through
751
+ # server-initiated requests; either way they are declared only when
752
+ # the host registered a handler, since the server MUST NOT ask for
753
+ # what the client did not declare.
235
754
  if registered_callback?(:@elicitation_request_callback)
236
755
  # Both defined elicitation modes are implemented (an empty object
237
756
  # would mean form-only per the spec's backwards-compatibility rule).
238
757
  capabilities['elicitation'] = { 'form' => {}, 'url' => {} }
239
758
  end
240
- capabilities['roots'] = { 'listChanged' => true } if registered_callback?(:@roots_list_request_callback)
759
+ if registered_callback?(:@roots_list_request_callback)
760
+ # notifications/roots/list_changed was removed in 2026-07-28, so the
761
+ # modern roots capability has no listChanged flag.
762
+ capabilities['roots'] = modern? ? {} : { 'listChanged' => true }
763
+ end
241
764
  if registered_callback?(:@sampling_request_callback)
242
765
  # SEP-1577: servers may only send tool-enabled sampling requests when
243
766
  # the client declares the sampling.tools sub-capability.
244
767
  capabilities['sampling'] = sampling_tools_supported? ? { 'tools' => {} } : {}
245
768
  end
769
+ capabilities['extensions'] = declared_extensions.dup unless declared_extensions.empty?
246
770
  # NOTE: we intentionally do NOT declare a client `tasks` capability. That
247
771
  # capability marks the client as a RECEIVER of task-augmented
248
772
  # sampling/elicitation requests, which is not implemented here — this
@@ -256,6 +780,13 @@ module MCPClient
256
780
  # before connect so the initialize request advertises it; it only takes
257
781
  # effect when a sampling request callback is also registered, since
258
782
  # sampling.tools is a sub-capability of sampling.
783
+ #
784
+ # @deprecated Sampling is deprecated since MCP 2026-07-28 (SEP-2577);
785
+ # earliest removal is the first revision released on or after
786
+ # 2027-07-28, and this sub-capability goes with the capability it
787
+ # refines. Declaring it raises no notice of its own — serving a
788
+ # sampling/createMessage request does. Integrate directly with the LLM
789
+ # provider API instead.
259
790
  # @return [void]
260
791
  def declare_sampling_tools
261
792
  @sampling_tools_supported = true
@@ -272,14 +803,370 @@ module MCPClient
272
803
  instance_variable_defined?(:@sampling_tools_supported) && @sampling_tools_supported
273
804
  end
274
805
 
806
+ # SEP-1577 (schema.ts CreateMessageRequestParams.tools/.toolChoice): "The
807
+ # client MUST return an error if this field is provided but
808
+ # ClientCapabilities.sampling.tools is not declared." The 2025-11-25
809
+ # server-initiated path refuses here, before any handler sees the request,
810
+ # with the Invalid params code sampling.mdx § Error Handling uses; the
811
+ # multi round-trip path refuses the same way in InputRoundTrips.
812
+ # @param request_id [String, Integer] the JSON-RPC request ID
813
+ # @param params [Hash] the sampling/createMessage params
814
+ # @return [Boolean] true when the request was refused (and answered)
815
+ def refused_undeclared_sampling_tools?(request_id, params)
816
+ return false unless undeclared_sampling_tool_use?('sampling/createMessage', params)
817
+
818
+ # The line is a courtesy to the host; the refusal is the answer the peer
819
+ # is owed. A logger that fails here must not turn Invalid params into
820
+ # the dispatcher's Internal error.
821
+ begin
822
+ @logger.warn('Rejecting tool-enabled sampling request: sampling.tools capability not declared')
823
+ rescue StandardError
824
+ nil
825
+ end
826
+ send_error_response(request_id, -32_602,
827
+ 'Invalid params: tools/toolChoice provided but the sampling.tools ' \
828
+ 'capability was not declared')
829
+ true
830
+ end
831
+
832
+ # Result types defined by the core protocol (basic/index.mdx "ResultType").
833
+ # Extensions add more (e.g. "task"); the accepted set widens with the
834
+ # declared extensions this client implements (#accepted_result_types).
835
+ CORE_RESULT_TYPES = %w[complete input_required].freeze
836
+
837
+ # The result type each known result-type-adding extension introduces. A
838
+ # client advertises one of these only when it implements it (see
839
+ # #implemented_extension_result_types).
840
+ RESULT_TYPE_EXTENSIONS = { 'io.modelcontextprotocol/tasks' => 'task' }.freeze
841
+
842
+ # The only result type a handshake-era (legacy) server can validly send:
843
+ # the others were introduced with the discriminator itself.
844
+ LEGACY_RESULT_TYPES = %w[complete].freeze
845
+
846
+ # The MCP 2026-07-28 tasks extension (extensions/tasks): once declared in
847
+ # the per-request clientCapabilities, a server MAY answer a supported
848
+ # request with a CreateTaskResult (resultType "task").
849
+ TASKS_EXTENSION = 'io.modelcontextprotocol/tasks'
850
+
851
+ # Requests the tasks extension allows a CreateTaskResult for. "A client
852
+ # that receives CreateTaskResult in response to an unsupported request
853
+ # type MUST interpret this as an invalid response".
854
+ TASK_METHODS = %w[tools/call].freeze
855
+
856
+ # @return [Boolean] whether the host declared the tasks extension
857
+ def tasks_extension_declared?
858
+ declared_extensions.key?(TASKS_EXTENSION)
859
+ end
860
+
861
+ # The result types this client implements on top of the core ones, per
862
+ # extension: declaring the tasks extension makes a CreateTaskResult
863
+ # (resultType "task") an accepted answer on the requests it allows.
864
+ # @return [Hash{String => Array<String>}]
865
+ def implemented_extension_result_types
866
+ { TASKS_EXTENSION => ['task'] }
867
+ end
868
+
869
+ # The resultType of a result object. MCP 2026-07-28 makes the field
870
+ # required, but "for backward compatibility with servers implementing
871
+ # earlier protocol versions, which do not include resultType, clients
872
+ # MUST treat an absent resultType as 'complete'". Non-object results
873
+ # (lenient handling of older servers) are likewise complete.
874
+ # @param result [Object] a JSON-RPC result
875
+ # @return [Object] the resultType value, 'complete' when absent
876
+ def self.result_type(result)
877
+ return 'complete' unless result.is_a?(Hash)
878
+ return result['resultType'] if result.key?('resultType')
879
+ return result[:resultType] if result.key?(:resultType)
880
+
881
+ 'complete'
882
+ end
883
+
884
+ # Restore the wire spelling of a peer's own JSON object. JSON object keys
885
+ # are always strings, but a host's response middleware may symbolize the
886
+ # keys of everything it parses (Faraday's :json parser with
887
+ # symbolize_names) — the middleware ::result_type already tolerates for
888
+ # the resultType discriminator. Undoing it once, on the protocol object
889
+ # about to be read, keeps every lookup below (and the params the input
890
+ # handlers see) on the shape the protocol defines. Values are returned
891
+ # untouched, so an opaque requestState is still echoed verbatim.
892
+ # @param value [Object] a parsed JSON value
893
+ # @return [Object] the same value with Symbol keys spelled as Strings
894
+ def self.restore_wire_keys(value)
895
+ case value
896
+ when Hash
897
+ value.to_h { |key, member| [key.is_a?(Symbol) ? key.to_s : key, restore_wire_keys(member)] }
898
+ when Array
899
+ value.map { |member| restore_wire_keys(member) }
900
+ else
901
+ value
902
+ end
903
+ end
904
+
905
+ # Result types this transport accepts. Overridden (widened) by transports
906
+ # that negotiated a result-type-adding extension.
907
+ # @return [Array<String>]
908
+ def accepted_result_types
909
+ # input_required names the multi round-trip pattern, which exists only
910
+ # in modern revisions: a handshake-era server answering with it is
911
+ # malformed, and treating it as valid would let a wrapper flatten an
912
+ # unfinished result into an empty successful one.
913
+ return LEGACY_RESULT_TYPES unless modern?
914
+
915
+ extra = implemented_extension_result_types.select { |id, _| declared_extensions.key?(id) }.values.flatten
916
+ extra.empty? ? CORE_RESULT_TYPES : (CORE_RESULT_TYPES + extra).uniq.freeze
917
+ end
918
+
919
+ # Which request field mirrors into the Mcp-Name header (MCP 2026-07-28
920
+ # Streamable HTTP "Standard Request Headers"; the tasks extension adds
921
+ # taskId routing for its methods).
922
+ NAME_HEADER_SOURCES = {
923
+ 'tools/call' => 'name',
924
+ 'prompts/get' => 'name',
925
+ 'resources/read' => 'uri',
926
+ 'tasks/get' => 'taskId',
927
+ 'tasks/update' => 'taskId',
928
+ 'tasks/cancel' => 'taskId',
929
+ 'tasks/result' => 'taskId'
930
+ }.freeze
931
+
932
+ # Encode a parameter value for an MCP request header (Mcp-Name,
933
+ # Mcp-Param-*): strings as-is when header-safe, integers in decimal,
934
+ # booleans lowercase; anything not safely representable — non-ASCII,
935
+ # control characters, leading/trailing whitespace, an empty string, or a
936
+ # value that looks like the sentinel — as `=?base64?<b64 of UTF-8>?=`.
937
+ # @param value [String, Integer, true, false] the parameter value
938
+ # @return [String] the header value
939
+ def encode_header_value(value)
940
+ MCPClient::HeaderParams.encode_header_value(value)
941
+ end
942
+
943
+ # The HTTP headers a modern (2026-07-28) request must carry: the protocol
944
+ # version (matching the body's _meta), the method, and for named
945
+ # requests the name/URI (MCP 2026-07-28 Streamable HTTP "Request
946
+ # Metadata").
947
+ # @param request [Hash] the JSON-RPC request (String keys)
948
+ # @return [Hash{String => String}] header name => value
949
+ def modern_request_headers(request)
950
+ # The version the body was built with, not the transport's current one:
951
+ # a concurrent request may have switched versions in between, and the
952
+ # header MUST match the body's _meta.
953
+ meta = request['params'].is_a?(Hash) ? request['params']['_meta'] : nil
954
+ version = (meta.is_a?(Hash) && meta[META_PROTOCOL_VERSION]) || protocol_version
955
+ headers = { 'MCP-Protocol-Version' => version, 'Mcp-Method' => request['method'].to_s }
956
+ name = mcp_name_header_value(request)
957
+ headers['Mcp-Name'] = name if name
958
+ headers
959
+ end
960
+
961
+ # @param request [Hash] the JSON-RPC request
962
+ # @return [String, nil] the encoded Mcp-Name value, or nil when the method has none
963
+ def mcp_name_header_value(request)
964
+ key = NAME_HEADER_SOURCES[request['method']]
965
+ params = request['params']
966
+ return nil unless key && params.is_a?(Hash)
967
+
968
+ value = params.key?(key) ? params[key] : params[key.to_sym]
969
+ return nil if value.nil?
970
+
971
+ encode_header_value(value)
972
+ end
973
+
275
974
  # Process JSON-RPC response
276
975
  # @param response [Hash] the parsed JSON-RPC response
277
976
  # @return [Object] the result field from the response
278
977
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
279
- def process_jsonrpc_response(response)
280
- raise MCPClient::Errors::ServerError, response['error']['message'] if response['error']
978
+ # @raise [MCPClient::Errors::InvalidResultError] if the result's resultType is unrecognized
979
+ def process_jsonrpc_response(response, method: nil)
980
+ error = envelope_member(response, 'error')
981
+ raise MCPClient::Errors::ServerError.from_jsonrpc(error) if error
982
+
983
+ result = envelope_member(response, 'result')
984
+ validate_result_type!(result)
985
+ record_server_info(result, method: method)
986
+ result
987
+ end
988
+
989
+ # Client requests a server MAY answer with an InputRequiredResult (MCP
990
+ # 2026-07-28 basic/patterns/mrtr "Supported Requests"); on any other
991
+ # request such a result is invalid.
992
+ MRTR_METHODS = %w[tools/call resources/read prompts/get].freeze
993
+
994
+ # Ceiling on consecutive input_required answers to one logical request.
995
+ # Servers MAY keep asking, but an unbounded loop is a hostile server.
996
+ MAX_INPUT_ROUND_TRIPS = 10
997
+
998
+ # Pause before retrying an InputRequiredResult that asked for nothing
999
+ # (requestState only — e.g. a URL-mode elicitation still in progress out
1000
+ # of band). The client MAY retry immediately, but a tight loop would just
1001
+ # burn the round-trip budget; doubles up to the maximum.
1002
+ INPUT_RETRY_DELAY = 0.5
1003
+ INPUT_RETRY_MAX_DELAY = 5
1004
+
1005
+ # Drive a request through the multi round-trip pattern (MCP 2026-07-28
1006
+ # basic/patterns/mrtr): while the server answers with an
1007
+ # InputRequiredResult, fulfil its inputRequests through the registered
1008
+ # handlers and retry the original request — as an independent request
1009
+ # with a new id — carrying inputResponses keyed like the requests and
1010
+ # the opaque requestState echoed verbatim (omitted when the server sent
1011
+ # none). A result without inputRequests asks for nothing this client can
1012
+ # fulfil, so it is retried after a growing pause (INPUT_RETRY_DELAY) that
1013
+ # the host steers through {#on_input_required_wait} and that never runs
1014
+ # past the request timeout: the continuation is handed back instead, on
1015
+ # an error {#resume_input_required} accepts.
1016
+ # @param method [String] the JSON-RPC method
1017
+ # @param params [Hash] the original params
1018
+ # @param timeout [Numeric, nil] per-request timeout, also bounding the waits
1019
+ # @yieldparam params [Hash] params for one attempt (original, or with inputResponses)
1020
+ # @yieldreturn [Object] the attempt's result
1021
+ # @return [Object] the final (complete) result
1022
+ # @raise [MCPClient::Errors::InvalidResultError] input_required on an unsupported method
1023
+ # @raise [MCPClient::Errors::InputRequiredError] when a round trip cannot be fulfilled, is
1024
+ # cancelled or times out, or too many occur — carrying the continuation
1025
+ def resolve_input_round_trips(method, params, timeout = nil)
1026
+ result = yield(params)
1027
+ round_trips = 0
1028
+ delay = INPUT_RETRY_DELAY
1029
+ started = input_wait_clock
1030
+ deadline = input_wait_deadline(started, timeout)
1031
+ while MCPClient::JsonRpcCommon.result_type(result) == 'input_required'
1032
+ # Read on the wire spelling, whatever the transport's JSON middleware
1033
+ # did to the keys: a symbolized inputRequests/requestState would
1034
+ # otherwise be invisible here and the retry would go out with neither
1035
+ # the fulfilled answers nor the state the server MUST get back.
1036
+ result = MCPClient::JsonRpcCommon.restore_wire_keys(result)
1037
+ unless modern? && MRTR_METHODS.include?(method)
1038
+ raise MCPClient::Errors::InvalidResultError.new(
1039
+ "Invalid result: input_required is only valid for #{MRTR_METHODS.join(', ')} " \
1040
+ "on an MCP 2026-07-28 server, not #{method} (#{protocol_version})", data: result
1041
+ )
1042
+ end
1043
+
1044
+ round_trips += 1
1045
+ if round_trips > MAX_INPUT_ROUND_TRIPS
1046
+ raise MCPClient::Errors::InputRequiredError.new(
1047
+ "Server kept requesting input for #{method} after #{MAX_INPUT_ROUND_TRIPS} round trips", data: result
1048
+ )
1049
+ end
1050
+
1051
+ @logger.debug("#{method} requires input (round trip #{round_trips}); fulfilling and retrying")
1052
+ retry_params = retry_params_for(params, result)
1053
+ unless retry_params.key?('inputResponses')
1054
+ now = input_wait_clock
1055
+ wait = InputRequiredWait.new(rpc_method: method, round_trip: round_trips, delay: delay,
1056
+ request_state: result['requestState'], result: result,
1057
+ elapsed: now - started)
1058
+ delay = pace_input_round_trip(wait, deadline)
1059
+ end
1060
+ result = yield(retry_params)
1061
+ end
1062
+ mark_round_trip_result(round_trips.positive?)
1063
+ reject_task_result_on_unsupported_method!(method, result)
1064
+ result
1065
+ rescue MCPClient::Errors::InputRequiredError => e
1066
+ # Every failure of the round trip hands the continuation back: the
1067
+ # request it was driving, for #resume_input_required.
1068
+ e.request_method ||= method
1069
+ e.request_params ||= params
1070
+ e.transport ||= self
1071
+ raise
1072
+ end
1073
+
1074
+ # A CreateTaskResult is only a valid answer to the request types the
1075
+ # tasks extension covers (TASK_METHODS); anywhere else it is an invalid
1076
+ # response (extensions/tasks "Capability Negotiation").
1077
+ # @param method [String] the JSON-RPC method
1078
+ # @param result [Object] the final result
1079
+ # @return [void]
1080
+ # @raise [MCPClient::Errors::InvalidResultError]
1081
+ def reject_task_result_on_unsupported_method!(method, result)
1082
+ return unless MCPClient::JsonRpcCommon.result_type(result) == 'task'
1083
+ return if TASK_METHODS.include?(method)
1084
+
1085
+ raise MCPClient::Errors::InvalidResultError,
1086
+ "Invalid result: resultType \"task\" is only valid for #{TASK_METHODS.join(', ')}, not #{method}"
1087
+ end
1088
+
1089
+ # server/discover is not one of the request types the tasks extension
1090
+ # covers, so a CreateTaskResult there is invalid and MUST NOT be applied:
1091
+ # the probe would otherwise adopt a protocol version and install
1092
+ # capabilities out of a task creation, and the discovery-shaped members a
1093
+ # non-conforming server bolted onto it would override the discriminator.
1094
+ # The ordinary rejection ({#reject_task_result_on_unsupported_method!})
1095
+ # runs in the round-trip resolver, which discovery does not go through.
1096
+ #
1097
+ # Like the input_required sibling this is a ModernServerError, not an
1098
+ # InvalidResultError: resultType is a 2026-07-28 field, so a server that
1099
+ # answered with one is modern and the era is settled — it must never be
1100
+ # retried with the initialize handshake, nor sent on to the legacy
1101
+ # transports by MCPClient.connect.
1102
+ # @param result [Object] the server/discover result
1103
+ # @return [void]
1104
+ # @raise [MCPClient::Errors::ModernServerError] if the result is a CreateTaskResult
1105
+ def reject_task_result_discover!(result)
1106
+ return unless MCPClient::JsonRpcCommon.result_type(result) == 'task'
1107
+
1108
+ raise MCPClient::Errors::ModernServerError,
1109
+ 'Server answered server/discover with a task result; resultType "task" is ' \
1110
+ "only valid for #{TASK_METHODS.join(', ')}"
1111
+ end
1112
+
1113
+ # Notifications the 2026-07-28 revision removed; never written to a
1114
+ # modern server (the roots capability has no listChanged there).
1115
+ REMOVED_MODERN_NOTIFICATIONS = %w[notifications/roots/list_changed notifications/initialized].freeze
1116
+
1117
+ # @param method [String] a notification method
1118
+ # @return [Boolean] whether it must be dropped for a modern server
1119
+ def suppressed_modern_notification?(method)
1120
+ modern? && REMOVED_MODERN_NOTIFICATIONS.include?(method)
1121
+ end
1122
+
1123
+ # Servers SHOULD identify themselves in every result's `_meta`
1124
+ # (`io.modelcontextprotocol/serverInfo`, MCP 2026-07-28); keep the latest
1125
+ # self-reported identity for display and logging.
1126
+ # @param result [Object] a JSON-RPC result
1127
+ # @param method [String, nil] the method the result answers, when known
1128
+ # @return [void]
1129
+ def record_server_info(result, method: nil)
1130
+ return unless result.is_a?(Hash)
1131
+ # A DiscoverResult's identity is recorded by apply_discover_result, once
1132
+ # the WHOLE result has validated: a refresh that fails must change
1133
+ # nothing, not even the identity it carried. Judged by the method the
1134
+ # result answers rather than by the result's own shape — an answer that
1135
+ # is missing supportedVersions is exactly the one apply_discover_result
1136
+ # rejects, and reading the shape recorded its identity first. The shape
1137
+ # still stands in for the method where the caller cannot name it.
1138
+ return if method == 'server/discover' || result.key?('supportedVersions')
1139
+
1140
+ info = result['_meta'].is_a?(Hash) ? result['_meta'][META_SERVER_INFO] : nil
1141
+ @server_info = info if info.is_a?(Hash)
1142
+ end
1143
+
1144
+ # "A resultType of any value unrecognized by the client MUST be
1145
+ # considered invalid" (basic/index.mdx). The value is peer-controlled, so
1146
+ # only its class or a short prefix reaches the exception message.
1147
+ # @param result [Object] a JSON-RPC result
1148
+ # @return [void]
1149
+ # @raise [MCPClient::Errors::InvalidResultError]
1150
+ def validate_result_type!(result)
1151
+ unless result.is_a?(Hash)
1152
+ # A modern result MUST be an object. Legacy servers occasionally
1153
+ # answered list requests with a bare array; keep tolerating that.
1154
+ return unless modern?
1155
+
1156
+ raise MCPClient::Errors::InvalidResultError, "Invalid result: expected an object, got #{result.class}"
1157
+ end
1158
+
1159
+ type = MCPClient::JsonRpcCommon.result_type(result)
1160
+ return if type.is_a?(String) && accepted_result_types.include?(type)
281
1161
 
282
- response['result']
1162
+ shown = type.is_a?(String) ? type[0, 64].inspect : type.class.name
1163
+ # The refused result travels with the error: only a modern server names
1164
+ # a resultType at all, which is how the discovery probe tells a modern
1165
+ # server's unusable answer from a legacy endpoint's.
1166
+ raise MCPClient::Errors::InvalidResultError.new(
1167
+ "Invalid result: unrecognized resultType #{shown} (accepted: #{accepted_result_types.join(', ')})",
1168
+ data: result
1169
+ )
283
1170
  end
284
1171
  end
285
1172
  end