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
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module MCPClient
6
+ # Fulfilment of the input requests an `input_required` result carries
7
+ # (MCP 2026-07-28 multi-round tool requests). Mixed into
8
+ # {MCPClient::JsonRpcCommon}, whose host supplies the registered handlers.
9
+ module InputRoundTrips
10
+ # Input request methods and the transport callback that fulfils each.
11
+ INPUT_REQUEST_HANDLERS = {
12
+ 'elicitation/create' => :@elicitation_request_callback,
13
+ 'sampling/createMessage' => :@sampling_request_callback,
14
+ 'roots/list' => :@roots_list_request_callback
15
+ }.freeze
16
+
17
+ # Fulfil every input request through the handler registered for its
18
+ # method. There is no per-key error channel in InputResponses, so any
19
+ # request this client cannot honour fails the whole round trip.
20
+ #
21
+ # The answers produced before the failure travel with the error
22
+ # ({MCPClient::Errors::InputRequiredError#answered_so_far}): a request the
23
+ # host already answered has been put to a person, and a caller that can
24
+ # keep it — the tasks extension's poll loop — must not ask them again.
25
+ # @param input_requests [Hash] the InputRequests map
26
+ # @param result [Hash] the InputRequiredResult (for error data)
27
+ # @return [Hash] the InputResponses map
28
+ # @raise [MCPClient::Errors::InputRequiredError]
29
+ def fulfil_input_requests(input_requests, result)
30
+ unless input_requests.is_a?(Hash)
31
+ raise MCPClient::Errors::InputRequiredError.new('Malformed InputRequiredResult: inputRequests is not an object',
32
+ data: result)
33
+ end
34
+
35
+ responses = {}
36
+ input_requests.each do |key, request|
37
+ responses[key] = fulfil_input_request(key, request, result)
38
+ rescue MCPClient::Errors::InputRequiredError => e
39
+ raise e.with_answered_so_far(responses)
40
+ end
41
+ responses
42
+ end
43
+
44
+ # @param key [String] the server-assigned request key
45
+ # @param request [Hash] the input request ({ 'method' => ..., 'params' => ... })
46
+ # @param result [Hash] the InputRequiredResult (for error data)
47
+ # @return [Hash] the handler's result
48
+ # @raise [MCPClient::Errors::InputRequiredError]
49
+ def fulfil_input_request(key, request, result)
50
+ shown_key = sanitize_log_text(key.to_s.inspect)
51
+ unless request.is_a?(Hash) && request['method'].is_a?(String) &&
52
+ (request['params'].nil? || request['params'].is_a?(Hash))
53
+ raise MCPClient::Errors::InputRequiredError.new("Malformed input request #{shown_key} (method/params)",
54
+ data: result)
55
+ end
56
+
57
+ request_method = request['method']
58
+ shown_method = sanitize_log_text(request_method.inspect)
59
+ handler_ivar = INPUT_REQUEST_HANDLERS[request_method]
60
+ unless handler_ivar
61
+ raise MCPClient::Errors::InputRequiredError.new(
62
+ "Unsupported input request method #{shown_method} for key #{shown_key}", data: result
63
+ )
64
+ end
65
+ unless registered_callback?(handler_ivar)
66
+ raise MCPClient::Errors::InputRequiredError.new(
67
+ "Server requested #{shown_method} (key #{shown_key}) but no handler is registered for it " \
68
+ '(the capability was not declared)', data: result
69
+ )
70
+ end
71
+
72
+ # The handler this reaches is the same callback a legacy
73
+ # server-initiated request would have used, so Roots and Sampling are
74
+ # just as deprecated here (SEP-2577). The notice precedes the
75
+ # sampling.tools refusal below, as it precedes the handler's own
76
+ # -32602 on the server-initiated path: the deprecated values were on
77
+ # the wire and a host with a handler asked for them, whichever
78
+ # sub-capability the request then trips over.
79
+ warn_input_request_deprecated(request_method, request['params'])
80
+ if undeclared_sampling_tool_use?(request_method, request['params'])
81
+ raise MCPClient::Errors::InputRequiredError.new(
82
+ "Server requested tool-enabled #{shown_method} (key #{shown_key}) but the sampling.tools " \
83
+ 'capability was not declared', data: result
84
+ )
85
+ end
86
+
87
+ begin
88
+ response = instance_variable_get(handler_ivar).call(key, request['params'] || {})
89
+ rescue StandardError => e
90
+ # The exception text is host-internal; it stays in the local log.
91
+ @logger.error("Handler for #{shown_method} (key #{shown_key}) raised: #{e.message}")
92
+ raise MCPClient::Errors::InputRequiredError.new(
93
+ "Handler for #{shown_method} (key #{shown_key}) failed", data: result
94
+ )
95
+ end
96
+ unless response.is_a?(Hash)
97
+ raise MCPClient::Errors::InputRequiredError.new(
98
+ "Handler for #{shown_method} (key #{shown_key}) returned #{response.class}, expected a result object",
99
+ data: result
100
+ )
101
+ end
102
+ if (error = response['error'] || response[:error])
103
+ message = error.is_a?(Hash) ? (error['message'] || error[:message]) : error
104
+ raise MCPClient::Errors::InputRequiredError.new(
105
+ "Handler for #{shown_method} (key #{shown_key}) failed: #{sanitize_log_text(message)}", data: result
106
+ )
107
+ end
108
+
109
+ warn_input_request_answer_deprecated(request_method, response)
110
+ response
111
+ end
112
+
113
+ # SEP-1577 (sampling tool calling): a server MUST NOT send `tools` or
114
+ # `toolChoice` to a client that did not declare the sampling.tools
115
+ # sub-capability. On a server-initiated request the client answers -32602;
116
+ # InputResponses has no per-request error channel, so on the multi
117
+ # round-trip path the whole round trip fails instead — the sampler is
118
+ # never invoked with a request this client never advertised support for.
119
+ # @param method [String] the input request method
120
+ # @param params [Hash, nil] the input request params
121
+ # @return [Boolean] whether this is tool-enabled sampling without the declaration
122
+ def undeclared_sampling_tool_use?(method, params)
123
+ return false unless method == 'sampling/createMessage' && !sampling_tools_supported?
124
+
125
+ params.is_a?(Hash) && (params.key?('tools') || params.key?('toolChoice'))
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module JsonRpcCommon
5
+ # Reading the members of a decoded JSON-RPC envelope. A host's JSON
6
+ # middleware may hand the envelope over keyed by Symbol, and its members
7
+ # are the peer's rather than the host's, so both spellings name the same
8
+ # thing. Mixed into {MCPClient::JsonRpcCommon}; private there.
9
+ module Envelopes
10
+ private
11
+
12
+ # Read a member of a decoded JSON-RPC envelope.
13
+ #
14
+ # The README offers Faraday's JSON middleware for reading a server's error
15
+ # bodies, and that middleware decodes every response — under
16
+ # `symbolize_names` the envelope arrives keyed by Symbol. The members are
17
+ # the peer's, not the host's, so both spellings name the same thing: read
18
+ # the wire spelling first and fall back to the Symbol one. Anything that
19
+ # is no Hash is indexed as before, so a malformed envelope fails where it
20
+ # always did.
21
+ # @param response [Object] the decoded JSON-RPC envelope
22
+ # @param name [String] the member's name in its wire spelling
23
+ # @return [Object, nil] the member, or nil when the envelope carries none
24
+ def envelope_member(response, name)
25
+ return response[name] unless response.is_a?(Hash)
26
+ return response[name] if response.key?(name)
27
+
28
+ response[name.to_sym]
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'stringio'
5
+
6
+ module MCPClient
7
+ module JsonRpcCommon
8
+ # Reading a JSON-RPC error object out of an HTTP error response body,
9
+ # bounded in size and inflated only within that bound. Mixed into
10
+ # {MCPClient::JsonRpcCommon}, so every transport that includes it has
11
+ # these helpers.
12
+ module ErrorBodies
13
+ # Build the error for a 4xx response: the typed JSON-RPC error when the
14
+ # body is a JSON-RPC error response (with the HTTP status prefixed to the
15
+ # peer's message), otherwise a plain ServerError with the fallback text.
16
+ # @param response [Faraday::Response] the 4xx response
17
+ # @param fallback [String] message when the body carries no JSON-RPC error
18
+ # @return [MCPClient::Errors::ServerError]
19
+ def jsonrpc_error_from_http_response(response, fallback)
20
+ status = response.status
21
+ error = jsonrpc_error_in_body(response)
22
+ return MCPClient::Errors::ServerError.new(fallback).tap { |e| e.http_status = status } unless error
23
+
24
+ typed = MCPClient::Errors::ServerError.from_jsonrpc(error)
25
+ typed.class.new("#{fallback}: #{typed.message}", code: typed.code, data: typed.data)
26
+ .tap { |e| e.http_status = status }
27
+ end
28
+
29
+ # Ceiling on the size of an HTTP error body inspected for a JSON-RPC
30
+ # error. A protocol error response is a few hundred bytes; the body is
31
+ # peer-controlled, so anything larger is not parsed at all rather than
32
+ # handed to JSON.parse.
33
+ MAX_ERROR_BODY_BYTES = 64 * 1024
34
+
35
+ # Extract a JSON-RPC error object from an HTTP error body, if there is one.
36
+ # Only a JSON-RPC 2.0 error response is recognized; anything else is
37
+ # ignored.
38
+ # @param response [Faraday::Response] the HTTP response
39
+ # @return [Hash, nil] the JSON-RPC `error` member, or nil
40
+ def jsonrpc_error_in_body(response)
41
+ return nil unless response.respond_to?(:body)
42
+
43
+ data = decoded_error_body(response)
44
+ # Only a JSON-RPC 2.0 error response counts; an arbitrary JSON body
45
+ # with an "error" member is not a protocol error.
46
+ return nil unless data.is_a?(Hash) && (data['jsonrpc'] || data[:jsonrpc]) == '2.0'
47
+
48
+ error = data['error'] || data[:error]
49
+ error.is_a?(Hash) ? error : nil
50
+ end
51
+
52
+ # The error body as a decoded object.
53
+ #
54
+ # A host may configure the connection (faraday_config) with response
55
+ # middleware — `conn.response :json` — that decodes the body before it
56
+ # reaches this transport, on the exception path (`raise_error`) as well
57
+ # as the response path. That already-parsed body carries the same
58
+ # protocol error, so it is accepted as-is; only a raw String body is
59
+ # size-bounded, gunzipped and parsed here (the middleware has already
60
+ # spent the memory for the ones it decoded).
61
+ # @param response [Faraday::Response] the HTTP response
62
+ # @return [Object, nil] the decoded body, or nil when it cannot be read
63
+ def decoded_error_body(response)
64
+ body = response.body
65
+ return body if body.is_a?(Hash)
66
+ return nil unless body.is_a?(String) && !body.empty?
67
+ return nil if oversized_error_body?(body)
68
+
69
+ headers = response.respond_to?(:headers) ? response.headers || {} : {}
70
+ encoding = headers['content-encoding'] || headers['Content-Encoding'] || ''
71
+ body = gunzip_bounded(body) if encoding.include?('gzip')
72
+ return nil if body.nil?
73
+
74
+ JSON.parse(body)
75
+ rescue JSON::ParserError, Zlib::Error => e
76
+ @logger.debug("HTTP error body is not a JSON-RPC error: #{e.class}")
77
+ nil
78
+ end
79
+
80
+ # @param body [String] an HTTP error body
81
+ # @return [Boolean] whether it exceeds the inspection ceiling (logged)
82
+ def oversized_error_body?(body)
83
+ return false if body.bytesize <= MAX_ERROR_BODY_BYTES
84
+
85
+ @logger.debug("Ignoring HTTP error body of #{body.bytesize} bytes (over #{MAX_ERROR_BODY_BYTES})")
86
+ true
87
+ end
88
+
89
+ # Decompress a gzip error body, giving up once the expansion passes the
90
+ # inspection ceiling (a compressed 4xx body is peer-controlled too).
91
+ # @param body [String] gzip data
92
+ # @return [String, nil] the decompressed body, or nil when too large
93
+ def gunzip_bounded(body)
94
+ reader = Zlib::GzipReader.new(StringIO.new(body))
95
+ expanded = reader.read(MAX_ERROR_BODY_BYTES + 1) || ''
96
+ return expanded if expanded.bytesize <= MAX_ERROR_BODY_BYTES
97
+
98
+ @logger.debug("Ignoring gzip HTTP error body expanding past #{MAX_ERROR_BODY_BYTES} bytes")
99
+ nil
100
+ ensure
101
+ reader&.close
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module JsonRpcCommon
5
+ # The host's hand on a multi round-trip request that waits on an
6
+ # interaction happening out of band (MCP 2026-07-28 client/elicitation
7
+ # "URL Mode": "Clients SHOULD provide manual controls that let the user
8
+ # retry or cancel the original request"): the pause before each retry of
9
+ # a requestState-only answer is the host's to steer, it never runs past
10
+ # the request timeout, and a stopped request resumes from the
11
+ # continuation its error carries. Mixed into {MCPClient::JsonRpcCommon}.
12
+ module InputWaits
13
+ # What the host is told before each paced retry of a continuation that
14
+ # asked for nothing (an out-of-band interaction still in progress): the
15
+ # request, how many round trips it has taken, the pause about to be
16
+ # taken, the server's requestState, the InputRequiredResult itself and
17
+ # the seconds spent waiting so far.
18
+ InputRequiredWait = Struct.new(:rpc_method, :round_trip, :delay, :request_state, :result, :elapsed,
19
+ keyword_init: true)
20
+
21
+ # An InputRequiredResult is defined only for tools/call, resources/read
22
+ # and prompts/get (MCP 2026-07-28 basic/patterns/mrtr "Supported
23
+ # Requests"). server/discover is not one of them, so an input_required
24
+ # discover answer is invalid and MUST NOT be applied or cached: the probe
25
+ # would otherwise adopt a protocol version out of an unfinished result
26
+ # and hand that result back as the first heartbeat. The rejection is a
27
+ # ModernServerError, not an InvalidResultError, because a server
28
+ # answering server/discover with a 2026-07-28-only discriminator is
29
+ # modern: the era is settled, so it must never be retried with the
30
+ # initialize handshake, and MCPClient.connect must not send it on to the
31
+ # legacy SSE and HTTP+POST transports either.
32
+ # @param result [Hash] the server/discover result
33
+ # @return [void]
34
+ # @raise [MCPClient::Errors::ModernServerError] if the result is an InputRequiredResult
35
+ def reject_input_required_discover!(result)
36
+ return unless MCPClient::JsonRpcCommon.result_type(result) == 'input_required'
37
+
38
+ raise MCPClient::Errors::ModernServerError,
39
+ 'Server answered server/discover with an input_required result; multi round-trip requests are ' \
40
+ "only valid for #{MRTR_METHODS.join(', ')}"
41
+ end
42
+
43
+ # Register the host's control over an out-of-band wait (MCP 2026-07-28
44
+ # client/elicitation "URL Mode": "Clients SHOULD provide manual controls
45
+ # that let the user retry or cancel the original request"). The block is
46
+ # called with an {InputRequiredWait} before each paced retry of a
47
+ # continuation that asked for nothing; it returns `:retry` to retry at
48
+ # once, `:cancel` to stop with an {MCPClient::Errors::InputRequiredError}
49
+ # the host can hand to {#resume_input_required} later, or anything else
50
+ # to wait the pace and retry.
51
+ # @yieldparam wait [InputRequiredWait] the wait about to be paced
52
+ # @yieldreturn [Symbol, Object] :retry, :cancel, or anything else to wait
53
+ # @return [void]
54
+ def on_input_required_wait(&block)
55
+ @input_required_wait_callback = block
56
+ end
57
+
58
+ # Resume a multi round-trip request from the continuation an
59
+ # {MCPClient::Errors::InputRequiredError} carries: the original request
60
+ # goes out again as a new request with the server's requestState echoed
61
+ # and no inputResponses, and the round trip continues from there.
62
+ # @param error [MCPClient::Errors::InputRequiredError] a resumable error
63
+ # @param timeout [Numeric, nil] per-request timeout for the resumed request
64
+ # @return [Object] the final (complete) result
65
+ # @raise [ArgumentError] if the error carries no continuation
66
+ def resume_input_required(error, timeout: nil)
67
+ unless error.is_a?(MCPClient::Errors::InputRequiredError) && error.resumable?
68
+ raise ArgumentError, 'the error carries no continuation to resume'
69
+ end
70
+
71
+ params = error.request_params.is_a?(Hash) ? error.request_params.dup : {}
72
+ %w[inputResponses requestState].each do |key|
73
+ params.delete(key)
74
+ params.delete(key.to_sym)
75
+ end
76
+ params['requestState'] = error.request_state unless error.request_state.nil?
77
+ ensure_initialized if respond_to?(:ensure_initialized, true)
78
+ rpc_request(error.request_method, params, timeout: timeout)
79
+ end
80
+
81
+ private
82
+
83
+ # The params for a multi round-trip retry: the original params plus the
84
+ # fulfilled inputResponses and the server's requestState. Both fields
85
+ # affect only this retry; the caller's params are not mutated.
86
+ # @param params [Hash] the original params
87
+ # @param result [Hash] the InputRequiredResult
88
+ # @return [Hash]
89
+ def retry_params_for(params, result)
90
+ retry_params = (params.is_a?(Hash) ? params.dup : {})
91
+ retry_params.delete('inputResponses')
92
+ retry_params.delete(:inputResponses)
93
+ retry_params.delete('requestState')
94
+ retry_params.delete(:requestState)
95
+
96
+ if result.key?('inputRequests')
97
+ retry_params['inputResponses'] = fulfil_input_requests(result['inputRequests'], result)
98
+ end
99
+ state = result['requestState']
100
+ retry_params['requestState'] = state unless state.nil?
101
+ retry_params
102
+ end
103
+
104
+ # One pause of a continuation that asked for nothing, as the host steers
105
+ # it. Returns the pace for the next such pause.
106
+ #
107
+ # The host's control is host code: it may open a window, ask a person
108
+ # and come back much later. That time belongs to the request, so the
109
+ # deadline is read against the clock as it stands when the control
110
+ # returns, not as it stood when the answer arrived — otherwise a
111
+ # control that deliberates for most of the timeout still buys itself a
112
+ # full pause and another request on top of it.
113
+ # @param wait [InputRequiredWait] what the host is told
114
+ # @param deadline [Float, nil] the request timeout on the wait clock
115
+ # @return [Numeric] the next delay
116
+ # @raise [MCPClient::Errors::InputRequiredError] cancelled, or out of time for the pause
117
+ def pace_input_round_trip(wait, deadline)
118
+ decision = @input_required_wait_callback&.call(wait)
119
+ now = input_wait_clock
120
+ if decision == :cancel
121
+ raise MCPClient::Errors::InputRequiredError.new(
122
+ "#{wait.rpc_method} cancelled by the host while waiting for out-of-band input " \
123
+ "(round trip #{wait.round_trip})", data: wait.result
124
+ )
125
+ end
126
+ # "Retry now" skips the pause, so only a deadline that has already
127
+ # passed stops it: the request it would re-send is over either way.
128
+ pause = decision == :retry ? 0 : wait.delay
129
+ if deadline && now + pause > deadline
130
+ raise MCPClient::Errors::InputRequiredError.new(
131
+ "#{wait.rpc_method} is still waiting for out-of-band input at the request timeout " \
132
+ "(round trip #{wait.round_trip}); resume it from the continuation", data: wait.result
133
+ )
134
+ end
135
+ return wait.delay if decision == :retry
136
+
137
+ sleep(wait.delay)
138
+ [wait.delay * 2, INPUT_RETRY_MAX_DELAY].min
139
+ end
140
+
141
+ # @param started [Float] the wait clock when the request began
142
+ # @param timeout [Numeric, nil] the per-request timeout, when the caller gave one
143
+ # @return [Float, nil] the wait clock reading the waits of this request must not pass
144
+ def input_wait_deadline(started, timeout)
145
+ bound = input_wait_timeout(timeout)
146
+ bound ? started + bound : nil
147
+ end
148
+
149
+ # The bound an out-of-band wait is measured against. A caller that named
150
+ # no timeout still runs under one — the transport's configured read
151
+ # timeout — and a wait that outlived it would outlive the request it
152
+ # belongs to. A transport that bounds nothing (a host adapter that sets
153
+ # no read timeout) leaves the round-trip ceiling as the only limit.
154
+ # @param timeout [Numeric, nil] the per-request timeout, when the caller gave one
155
+ # @return [Numeric, nil] the seconds the waits of this request share
156
+ def input_wait_timeout(timeout)
157
+ bound = timeout || (@read_timeout if defined?(@read_timeout))
158
+ bound if bound.is_a?(Numeric) && bound.positive?
159
+ end
160
+
161
+ # @return [Float] the monotonic clock, in seconds, the out-of-band waits are bounded by
162
+ def input_wait_clock
163
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
164
+ end
165
+ end
166
+ end
167
+ end