ruby-mcp-client 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -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
|