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,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module HttpTransportBase
|
|
5
|
+
# What a response stream that broke leaves behind, and what to make of it.
|
|
6
|
+
#
|
|
7
|
+
# MCP 2026-07-28 has no resumption: "a broken response stream loses the
|
|
8
|
+
# in-flight request; clients MUST re-issue it as a new request with a new
|
|
9
|
+
# request ID". Faraday discards a partially read body and raises, so
|
|
10
|
+
# without capturing the bytes as they arrive a break *after* the final
|
|
11
|
+
# event is indistinguishable from one before it -- and re-issuing then
|
|
12
|
+
# runs a completed call a second time.
|
|
13
|
+
module StreamRecovery
|
|
14
|
+
# Innermost Faraday middleware: streams the response body into a
|
|
15
|
+
# per-request buffer so that
|
|
16
|
+
#
|
|
17
|
+
# 1. a socket failure mid-body still leaves the bytes that did arrive
|
|
18
|
+
# (Faraday discards a partially read body and raises), letting a
|
|
19
|
+
# response that was fully delivered settle its request instead of being
|
|
20
|
+
# re-issued and executed twice; and
|
|
21
|
+
# 2. a deadline can be enforced while the body is arriving, which a socket
|
|
22
|
+
# timeout alone cannot do for a stream that keeps dripping keep-alives.
|
|
23
|
+
#
|
|
24
|
+
# It restores the buffer as the response body, and being the innermost
|
|
25
|
+
# handler its on_complete runs before any user middleware (raise_error and
|
|
26
|
+
# friends) looks at that body.
|
|
27
|
+
class ResponseBodyCapture < Faraday::Middleware
|
|
28
|
+
# @param env [Faraday::Env] the outgoing request environment
|
|
29
|
+
# @return [void]
|
|
30
|
+
def on_request(env)
|
|
31
|
+
state = env.request&.context
|
|
32
|
+
buffer = state && state[:mcp_body_buffer]
|
|
33
|
+
return unless buffer
|
|
34
|
+
|
|
35
|
+
# The retry middleware sits above this one and replays the whole inner
|
|
36
|
+
# stack, so each attempt must start from an empty buffer (and from an
|
|
37
|
+
# empty event scanner: the count of events dispatched while the body
|
|
38
|
+
# arrived belongs to the attempt whose body is finally parsed).
|
|
39
|
+
buffer.clear
|
|
40
|
+
listener = state[:mcp_stream_listener]
|
|
41
|
+
scanner = listener && SseEventScanner.new(max_inflated_bytes: state[:mcp_inflate_limit])
|
|
42
|
+
state[:mcp_live_events] = 0
|
|
43
|
+
# The adapter fills this same env in as it reads: its status is set
|
|
44
|
+
# from the status line, so a salvaged answer can be rebuilt under the
|
|
45
|
+
# status it really arrived with. The era rule reads a recognized
|
|
46
|
+
# modern error only under the status it came with.
|
|
47
|
+
state[:mcp_env] = env
|
|
48
|
+
env.request.on_data = lambda do |chunk, _size, _env|
|
|
49
|
+
# Before the chunk is kept, never after: bytes that arrive past the
|
|
50
|
+
# deadline are not part of an answer this request may settle on, and
|
|
51
|
+
# buffering them first would let the salvage hand back an answer the
|
|
52
|
+
# caller had already stopped waiting for.
|
|
53
|
+
deadline = state[:mcp_deadline]
|
|
54
|
+
raise Faraday::TimeoutError, 'Request exceeded its deadline' if deadline && monotonic_now > deadline
|
|
55
|
+
|
|
56
|
+
buffer << chunk.to_s
|
|
57
|
+
# Only a streamed body can be measured against its Content-Length
|
|
58
|
+
# here; a response the adapter hands over whole never reaches this.
|
|
59
|
+
state[:mcp_streamed] = true
|
|
60
|
+
|
|
61
|
+
next unless scanner
|
|
62
|
+
|
|
63
|
+
# Every complete event is handed over as it arrives, so a server
|
|
64
|
+
# request or a progress notification on the stream is acted on
|
|
65
|
+
# while the response is still open (a server that waits for its
|
|
66
|
+
# ping to be answered before sending the result would otherwise
|
|
67
|
+
# deadlock against a client that answers only at EOF).
|
|
68
|
+
scanner.feed(chunk.to_s) do |event|
|
|
69
|
+
note_response_arrival(state, event)
|
|
70
|
+
deliver_live_event(state, listener, event)
|
|
71
|
+
end
|
|
72
|
+
state[:mcp_live_events] = scanner.count
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Hand one event to the stream listener. A failure there is the
|
|
77
|
+
# exchange's failure, but raising it here would abort the read — and
|
|
78
|
+
# on MCP 2026-07-28 a client closing the response stream is the
|
|
79
|
+
# cancellation signal — so the first failure is held for the
|
|
80
|
+
# transport to raise once the body has been read
|
|
81
|
+
# (StreamCapture#stream_listener_error).
|
|
82
|
+
# @param state [Hash] the exchange's capture state
|
|
83
|
+
# @param listener [Proc] the stream listener
|
|
84
|
+
# @param event [String] one complete SSE event
|
|
85
|
+
# @return [void]
|
|
86
|
+
def deliver_live_event(state, listener, event)
|
|
87
|
+
listener.call(event)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
state[:mcp_stream_error] ||= e
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Flag the event carrying the answer to this request, so whoever
|
|
93
|
+
# dates the response (CacheSupport's recorder) can date it from the
|
|
94
|
+
# chunk that completed the result rather than from whatever opened
|
|
95
|
+
# the stream: a keep-alive or a progress notification is not the
|
|
96
|
+
# result, and a TTL that ran from it would expire results that took
|
|
97
|
+
# a while to compute (MCP 2026-07-28 caching, "Freshness Calculation").
|
|
98
|
+
# @param state [Hash] the exchange's capture state
|
|
99
|
+
# @param event [String] one complete SSE event
|
|
100
|
+
# @return [void]
|
|
101
|
+
def note_response_arrival(state, event)
|
|
102
|
+
return if state[:mcp_response_seen] || !state.key?(:mcp_response_id)
|
|
103
|
+
|
|
104
|
+
state[:mcp_response_seen] = true if response_event?(event, state[:mcp_response_id])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @param event [String] one complete SSE event
|
|
108
|
+
# @param id [Integer, String] the id of the request awaiting its answer
|
|
109
|
+
# @return [Boolean] whether the event is the JSON-RPC response to it
|
|
110
|
+
def response_event?(event, id)
|
|
111
|
+
data = event.lines.filter_map { |line| line[5..].to_s.sub(/\A /, '').chomp if line.start_with?('data:') }
|
|
112
|
+
return false if data.empty?
|
|
113
|
+
|
|
114
|
+
message = JSON.parse(data.join("\n"))
|
|
115
|
+
message.is_a?(Hash) && !message.key?('method') && (message['id'] == id || message['id'].to_s == id.to_s)
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @param env [Faraday::Env] the completed request environment
|
|
121
|
+
# @return [void]
|
|
122
|
+
def on_complete(env)
|
|
123
|
+
state = env.request&.context
|
|
124
|
+
buffer = state && state[:mcp_body_buffer]
|
|
125
|
+
env.body = buffer.dup if buffer && env.body.to_s.empty?
|
|
126
|
+
state[:mcp_short_body] = short_body?(env, state, buffer) if state
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Whether a streamed body stopped short of the length it promised.
|
|
132
|
+
#
|
|
133
|
+
# A Content-Length body that ends early does not raise: Net::HTTP hands
|
|
134
|
+
# back what arrived as if it were whole, and only the promised length
|
|
135
|
+
# says the exchange was cut. Read as a malformed body it would look like
|
|
136
|
+
# a server that speaks bad JSON, and the request the stream took with it
|
|
137
|
+
# would never be re-issued.
|
|
138
|
+
# @param env [Faraday::Env] the completed request environment
|
|
139
|
+
# @param state [Hash] the capture state
|
|
140
|
+
# @param buffer [String, nil] the bytes this exchange streamed
|
|
141
|
+
# @return [Boolean]
|
|
142
|
+
def short_body?(env, state, buffer)
|
|
143
|
+
return false unless buffer && state[:mcp_streamed]
|
|
144
|
+
|
|
145
|
+
declared = env.response_headers && (env.response_headers['content-length'] ||
|
|
146
|
+
env.response_headers['Content-Length'])
|
|
147
|
+
return false if declared.nil? || !declared.to_s.match?(/\A\d+\z/)
|
|
148
|
+
|
|
149
|
+
buffer.bytesize < declared.to_i
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Float] a monotonic clock reading in seconds
|
|
153
|
+
def monotonic_now
|
|
154
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Translate a Faraday socket failure into the MCP error the caller must
|
|
159
|
+
# act on.
|
|
160
|
+
#
|
|
161
|
+
# A response stream that dies mid-body is what a broken stream actually
|
|
162
|
+
# looks like on the wire: Faraday raises rather than handing back a
|
|
163
|
+
# truncated body, so it never reaches the SSE parser that recognises a
|
|
164
|
+
# stream which closed *between* events. MCP 2026-07-28 has no resumption
|
|
165
|
+
# — "a broken response stream loses the in-flight request; clients MUST
|
|
166
|
+
# re-issue it as a new request with a new request ID" (changelog, major
|
|
167
|
+
# change 9) — and the rule does not care where the break landed. Raising
|
|
168
|
+
# ResponseStreamClosedError puts both breaks on the one re-issue path.
|
|
169
|
+
#
|
|
170
|
+
# A failure that never got the request out, and a notification (which has
|
|
171
|
+
# no response to lose), stay a plain ConnectionError.
|
|
172
|
+
# @param error [Faraday::ConnectionFailed, Faraday::SSLError] the socket failure
|
|
173
|
+
# @param request [Hash] the JSON-RPC message that was being sent
|
|
174
|
+
# @return [MCPClient::Errors::MCPError] the error to raise
|
|
175
|
+
def connection_failure_error(error, request)
|
|
176
|
+
if modern? && request.is_a?(Hash) && request.key?('id') && interrupted_exchange?(error)
|
|
177
|
+
return MCPClient::Errors::ResponseStreamClosedError.new(
|
|
178
|
+
"Response stream closed before delivering the response: #{error.message}"
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
MCPClient::Errors::ConnectionError.new("Server connection lost: #{error.message}")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Faraday wraps every socket failure in ConnectionFailed (or, for TLS, in
|
|
186
|
+
# SSLError), whether the connection was never established or it broke with
|
|
187
|
+
# a request in flight; only the wrapped exception distinguishes them.
|
|
188
|
+
# @param error [Faraday::ConnectionFailed, Faraday::SSLError] the socket failure
|
|
189
|
+
# @return [Boolean] true when the exchange had started when it broke
|
|
190
|
+
def interrupted_exchange?(error)
|
|
191
|
+
cause = (error.wrapped_exception if error.respond_to?(:wrapped_exception)) || error.cause
|
|
192
|
+
return false if tls_handshake_failure?(cause)
|
|
193
|
+
|
|
194
|
+
INTERRUPTED_EXCHANGE_ERRORS.any? { |klass| cause.is_a?(klass) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# OpenSSL names the failing operation in its message. A handshake that
|
|
198
|
+
# never completed ("SSL_connect ... certificate verify failed") means the
|
|
199
|
+
# request never left this client, so there is nothing in flight to
|
|
200
|
+
# replace; a body that dies mid-read ("SSL_read: unexpected eof while
|
|
201
|
+
# reading") is a broken response stream like any other.
|
|
202
|
+
# @param cause [Exception, nil] the exception Faraday wrapped
|
|
203
|
+
# @return [Boolean] true when TLS failed before the request was sent
|
|
204
|
+
def tls_handshake_failure?(cause)
|
|
205
|
+
cause.is_a?(OpenSSL::SSL::SSLError) && cause.message.to_s.include?('SSL_connect')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The response that did arrive before the socket died, when the stream
|
|
209
|
+
# carried this request's complete answer.
|
|
210
|
+
#
|
|
211
|
+
# Faraday discards a partially read body and raises, so without the
|
|
212
|
+
# streamed capture a break after the final SSE event is indistinguishable
|
|
213
|
+
# from a break before it — and re-issuing there would run a tools/call the
|
|
214
|
+
# server already executed a second time. MCP 2026-07-28's re-issue rule is
|
|
215
|
+
# about an in-flight request that was *lost*; a delivered response settles
|
|
216
|
+
# its request, however the socket ends afterwards.
|
|
217
|
+
# A socket that stalls after the final event until the timeout is the
|
|
218
|
+
# same case from the other direction: the answer arrived, the framing
|
|
219
|
+
# after it did not.
|
|
220
|
+
# @param partial_body [String, nil] the bytes captured before the failure
|
|
221
|
+
# @param request [Hash] the JSON-RPC message that was being sent
|
|
222
|
+
# @param error [Faraday::Error] the socket failure or timeout
|
|
223
|
+
# @param capture [Hash, nil] the capture state of the failed exchange
|
|
224
|
+
# @return [NormalizedResponse, nil] a response carrying the delivered answer
|
|
225
|
+
def salvaged_response(partial_body, request, error, capture = nil)
|
|
226
|
+
return nil unless modern? && request.is_a?(Hash) && request.key?('id')
|
|
227
|
+
return nil unless error.is_a?(Faraday::TimeoutError) || interrupted_exchange?(error)
|
|
228
|
+
|
|
229
|
+
body = partial_body.to_s
|
|
230
|
+
body = inflate_delivered_gzip(body) if body.b.start_with?(SseEventScanner::GZIP_MAGIC)
|
|
231
|
+
return nil if body.nil? || body.empty?
|
|
232
|
+
|
|
233
|
+
sse = sse_framed_body?(body)
|
|
234
|
+
# A truncated stream's last event has no terminating blank line, so it
|
|
235
|
+
# was never dispatched (HTML SSE parsing rules) and must be dropped
|
|
236
|
+
# before asking whether the answer arrived.
|
|
237
|
+
body = complete_sse_events(body) if sse
|
|
238
|
+
return nil if body.empty? || !body_carries_response?(body, sse, request['id'])
|
|
239
|
+
|
|
240
|
+
@logger.warn("Response stream ended after the response arrived (#{error.message}); " \
|
|
241
|
+
"keeping the delivered #{request['method']} response instead of re-issuing it")
|
|
242
|
+
NormalizedResponse.new(delivered_status(capture),
|
|
243
|
+
{ 'content-type' => sse ? 'text/event-stream' : 'application/json' }, body,
|
|
244
|
+
capture)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The status a salvaged answer arrived under. A well-formed -32022 in a
|
|
248
|
+
# 400 body identifies a modern server and is retried with an advertised
|
|
249
|
+
# version, while the same body under 200 is a permissive legacy echo:
|
|
250
|
+
# rebuilding every salvaged answer as 200 would turn the first into the
|
|
251
|
+
# second. The adapter fills the captured env in as it reads, so its status
|
|
252
|
+
# is the status line this response really carried.
|
|
253
|
+
# @param capture [Hash, nil] the capture state of the failed exchange
|
|
254
|
+
# @return [Integer]
|
|
255
|
+
def delivered_status(capture)
|
|
256
|
+
(capture.is_a?(Hash) && capture[:mcp_env]&.status) || 200
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# What a body that stopped short of its Content-Length settles: the
|
|
260
|
+
# answer if it is all there anyway (the bytes that arrived carry this
|
|
261
|
+
# request's response, and the rest was framing), otherwise the loss the
|
|
262
|
+
# re-issue rule is written for.
|
|
263
|
+
# @param request [Hash] the JSON-RPC message that was being sent
|
|
264
|
+
# @param capture [Hash] the capture state of the exchange
|
|
265
|
+
# @return [NormalizedResponse] the delivered answer
|
|
266
|
+
# @raise [MCPClient::Errors::MCPError] when the response was lost
|
|
267
|
+
def truncated_body_outcome(request, capture)
|
|
268
|
+
error = Faraday::ConnectionFailed.new(EOFError.new('response body stopped short of its Content-Length'))
|
|
269
|
+
salvaged = salvaged_response(capture[:mcp_body_buffer], request, error, capture)
|
|
270
|
+
return settled_salvage(salvaged) if salvaged
|
|
271
|
+
|
|
272
|
+
raise connection_failure_error(error, request)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A salvaged answer read the way the unbroken path reads one: an error
|
|
276
|
+
# status it arrived under still becomes the typed JSON-RPC error, so a
|
|
277
|
+
# recognized modern error keeps the status the era rule needs. Returning
|
|
278
|
+
# it unread would settle a 400 rejection as if it were a 200 result.
|
|
279
|
+
# @param salvaged [NormalizedResponse] the response the salvage rebuilt
|
|
280
|
+
# @return [NormalizedResponse] the same response, once it is an answer
|
|
281
|
+
# @raise [MCPClient::Errors::MCPError] whatever its status and body say
|
|
282
|
+
def settled_salvage(salvaged)
|
|
283
|
+
handle_http_error_response(salvaged) unless (200..299).cover?(salvaged.status.to_i)
|
|
284
|
+
salvaged
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Per the SSE specification a line is terminated by CRLF, CR or LF alone;
|
|
288
|
+
# normalizing to LF lets one set of framing rules serve all three.
|
|
289
|
+
# @param body [String] a response body
|
|
290
|
+
# @return [String] the body with LF line terminators
|
|
291
|
+
def normalize_sse_newlines(body)
|
|
292
|
+
without_bom(body).gsub(/\r\n|\r/, "\n")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The UTF-8 decode step of the SSE algorithm drops one leading byte-order
|
|
296
|
+
# mark; the field it precedes must still be recognized.
|
|
297
|
+
# @param body [String] a response body
|
|
298
|
+
# @return [String]
|
|
299
|
+
def without_bom(body)
|
|
300
|
+
bom = body.encoding == Encoding::BINARY ? SseEventScanner::BOM : "\uFEFF".encode(body.encoding)
|
|
301
|
+
body.start_with?(bom) ? body[bom.length..] : body
|
|
302
|
+
rescue EncodingError
|
|
303
|
+
body
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# @param body [String] an LF-normalized SSE body
|
|
307
|
+
# @return [Array<String>] the joined data payload of each event
|
|
308
|
+
def sse_data_payloads(body)
|
|
309
|
+
body.split("\n\n").filter_map do |event|
|
|
310
|
+
lines = event.lines.map(&:chomp).select { |line| line.start_with?('data:') }
|
|
311
|
+
next if lines.empty?
|
|
312
|
+
|
|
313
|
+
lines.map { |line| line.sub(/\Adata:\s*/, '') }.join("\n")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module HttpTransportBase
|
|
5
|
+
# The tool list an HTTP transport keeps, and everything derived from it:
|
|
6
|
+
# its generation counter (which tells a host that a mid-call refresh
|
|
7
|
+
# changed the definitions), the fetch that fills it, the invalidation a
|
|
8
|
+
# list_changed notification triggers, and the Mcp-Param-* headers a
|
|
9
|
+
# tools/call carries (MCP 2026-07-28 "Custom Headers from Tool
|
|
10
|
+
# Parameters") -- including the HeaderMismatch refresh-and-retry.
|
|
11
|
+
module ToolListing
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# MCP 2026-07-28 "Custom Headers from Tool Parameters": after a
|
|
15
|
+
# HeaderMismatch the client SHOULD re-fetch tools/list (the tool's
|
|
16
|
+
# inputSchema may have changed its x-mcp-header annotations) and retry
|
|
17
|
+
# the original request once with the appropriate headers. The server
|
|
18
|
+
# rejected the request before executing it, so the retry cannot
|
|
19
|
+
# duplicate a side effect. A refresh that fails re-raises the rejection:
|
|
20
|
+
# that is the actionable error.
|
|
21
|
+
# The list this refresh read is returned, not just cached: the retry
|
|
22
|
+
# goes out under the definition ITS OWN refresh brought. Two calls to
|
|
23
|
+
# one tool rejected at the same time each refresh, and the transport's
|
|
24
|
+
# cache holds whichever landed last — so a retry that re-read the cache
|
|
25
|
+
# could send another caller's definition.
|
|
26
|
+
# @param error [MCPClient::Errors::HeaderMismatchError] the rejection
|
|
27
|
+
# @return [Array<MCPClient::Tool>] the list this refresh fetched
|
|
28
|
+
def refresh_tools_after_header_mismatch(error)
|
|
29
|
+
@logger.warn("#{sanitize_log_text(error.message)}; refreshing tools/list and retrying tools/call once")
|
|
30
|
+
refresh_tools_cache
|
|
31
|
+
rescue MCPClient::Errors::MCPError => e
|
|
32
|
+
@logger.warn("tools/list refresh after HeaderMismatch failed: #{sanitize_log_text(e.message)}")
|
|
33
|
+
raise error
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# MCP 2026-07-28 basic "Implementation Requirements": a client "MUST
|
|
37
|
+
# handle unsupported dialects gracefully by returning an appropriate
|
|
38
|
+
# error indicating the dialect is not supported" — and, having done
|
|
39
|
+
# so, must not go on to act on the schema. MCPClient::Client refuses
|
|
40
|
+
# the dialect of the definition a call is prepared from, but the
|
|
41
|
+
# HeaderMismatch retry goes out under the definition the refresh
|
|
42
|
+
# brought instead, which this client never resolved. The rejection
|
|
43
|
+
# means the server did not execute the first attempt, so refusing the
|
|
44
|
+
# retry keeps the invariant the check is for: the call is never sent
|
|
45
|
+
# under a schema nothing could read.
|
|
46
|
+
# @param params [Hash] the tools/call params being re-sent
|
|
47
|
+
# @param refreshed [Array<MCPClient::Tool>, nil] the list this call's own
|
|
48
|
+
# refresh read; the transport's cache is consulted only without one
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @raise [MCPClient::Errors::ValidationError] when the refreshed input
|
|
51
|
+
# or output schema declares a dialect this client does not implement
|
|
52
|
+
def reject_unreadable_refreshed_schema!(params, refreshed = nil)
|
|
53
|
+
return unless params.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
name = (params['name'] || params[:name]).to_s
|
|
56
|
+
list = refreshed.is_a?(Array) ? refreshed : known_tools_for_headers
|
|
57
|
+
tool = list.find { |t| t.name.to_s == name }
|
|
58
|
+
# The definition checked here is the one the retry goes out under
|
|
59
|
+
# ({#mcp_param_headers} takes it): a second lookup could bring
|
|
60
|
+
# another, unchecked one.
|
|
61
|
+
pin_retry_definition(name, tool)
|
|
62
|
+
reject_unreadable_tool_schema!(name, tool)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Refuse a tool definition whose input or output schema declares a JSON
|
|
66
|
+
# Schema dialect this client cannot read, before the request it governs
|
|
67
|
+
# goes out: a tool run under an output dialect nothing here can read
|
|
68
|
+
# would only be refused after it ran, and the host would have paid for
|
|
69
|
+
# whatever it did.
|
|
70
|
+
# @param name [String] the tool name
|
|
71
|
+
# @param tool [MCPClient::Tool, nil] the definition the request goes out under
|
|
72
|
+
# @return [void]
|
|
73
|
+
# @raise [MCPClient::Errors::ValidationError]
|
|
74
|
+
def reject_unreadable_tool_schema!(name, tool)
|
|
75
|
+
return unless tool
|
|
76
|
+
|
|
77
|
+
{ 'input' => tool.schema, 'output' => tool.output_schema }.each do |side, schema|
|
|
78
|
+
dialect = schema.is_a?(Hash) && MCPClient::SchemaValidator.unsupported_dialect(schema)
|
|
79
|
+
next unless dialect
|
|
80
|
+
|
|
81
|
+
raise MCPClient::Errors::ValidationError,
|
|
82
|
+
"Tool #{sanitize_log_text(name.inspect)} #{side} schema declares the JSON Schema dialect " \
|
|
83
|
+
"#{sanitize_log_text(dialect.inspect)[0, 128]}: that dialect is not supported " \
|
|
84
|
+
"(supported: #{MCPClient::SchemaValidator::SUPPORTED_DIALECTS.join(', ')})"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The Mcp-Param-* headers for a tools/call request (MCP 2026-07-28
|
|
89
|
+
# "Custom Headers from Tool Parameters"): the annotated arguments of the
|
|
90
|
+
# tool, looked up in this transport's tool list (fetched on demand so a
|
|
91
|
+
# call issued before tools/list still carries them).
|
|
92
|
+
# @param request [Hash] the JSON-RPC request
|
|
93
|
+
# @return [Hash{String => String}]
|
|
94
|
+
# @raise [MCPClient::Errors::ValidationError] when an annotated argument cannot be mirrored
|
|
95
|
+
def mcp_param_headers(request)
|
|
96
|
+
return {} unless request['method'] == 'tools/call'
|
|
97
|
+
|
|
98
|
+
params = request['params']
|
|
99
|
+
return {} unless params.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
name = (params['name'] || params[:name]).to_s
|
|
102
|
+
pinned = take_pinned_retry_definition(name)
|
|
103
|
+
tool = pinned ? pinned.first : known_tools_for_headers.find { |t| t.name.to_s == name }
|
|
104
|
+
# The list the headers come from is the list this request goes out
|
|
105
|
+
# under: a host re-resolving the tool after the call reads that
|
|
106
|
+
# definition back instead of asking for a possibly newer one. It is
|
|
107
|
+
# also the definition the dialect guard has to read — this lookup may
|
|
108
|
+
# bring a newer one than the caller preflighted, and a pinned one has
|
|
109
|
+
# been checked already by the refresh that pinned it.
|
|
110
|
+
note_called_tool_definition(name, tool)
|
|
111
|
+
reject_unreadable_tool_schema!(name, tool) unless pinned
|
|
112
|
+
return {} unless tool
|
|
113
|
+
|
|
114
|
+
MCPClient::HeaderParams.headers_for(tool.schema, params['arguments'] || params[:arguments])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The tool list used for header extraction, fetched on demand. Mirroring
|
|
118
|
+
# is a MUST, so a list that cannot be fetched fails the call rather than
|
|
119
|
+
# letting it go out without the headers an intermediary may route on.
|
|
120
|
+
#
|
|
121
|
+
# That fetch is an exchange of its own, with a recovery of its own (its
|
|
122
|
+
# one re-issue, its own with_retry attempts), and it runs inside the
|
|
123
|
+
# call's recovery block: an error escaping it is marked so the call does
|
|
124
|
+
# not mistake it for its own rejection or lost stream and spend the one
|
|
125
|
+
# re-issue or refresh it has on a request that never went out.
|
|
126
|
+
# @return [Array<MCPClient::Tool>]
|
|
127
|
+
# @raise [MCPClient::Errors::MCPError] the list's own failure, marked NestedExchange
|
|
128
|
+
def known_tools_for_headers
|
|
129
|
+
fresh_list_value(:tools) { @mutex.synchronize { @tools } } || list_tools
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
e.extend(RequestRecovery::NestedExchange) unless e.frozen?
|
|
132
|
+
raise
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Drop the cached tool list and re-fetch it. Hosts layered above the
|
|
136
|
+
# transport (MCPClient::Client) keep their own tool cache, so the refresh
|
|
137
|
+
# is announced the way the server itself would: as a tools/list_changed
|
|
138
|
+
# notification.
|
|
139
|
+
# @return [void]
|
|
140
|
+
def refresh_tools_cache
|
|
141
|
+
invalidate_tools_cache
|
|
142
|
+
list_tools
|
|
143
|
+
ensure
|
|
144
|
+
announce_tools_list_changed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Tell the host its own copy is gone. This runs whether or not the
|
|
148
|
+
# re-fetch that followed the invalidation succeeded: the transport's
|
|
149
|
+
# list is already dropped by then, so a host that kept its copy would go
|
|
150
|
+
# on calling with definitions this client has thrown away, and it would
|
|
151
|
+
# never learn otherwise -- the server sends no notification for a
|
|
152
|
+
# refresh the client started. A listener that raises is the host's
|
|
153
|
+
# problem, not the caller's: the rejection that started the refresh is
|
|
154
|
+
# what the caller must see.
|
|
155
|
+
# @return [void]
|
|
156
|
+
def announce_tools_list_changed
|
|
157
|
+
# Announced on both hooks, in the order routing uses them, so a host
|
|
158
|
+
# whose cache invalidation runs ahead of subscription deliveries is
|
|
159
|
+
# told here too (see {MCPClient::ServerBase#on_cache_invalidation}).
|
|
160
|
+
notify_cache_invalidation('notifications/tools/list_changed', {})
|
|
161
|
+
@notification_callback&.call('notifications/tools/list_changed', {})
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
@logger.warn("Tool list invalidation listener failed: #{sanitize_log_text("#{e.class}: #{e.message}")}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Forget the cached tool list. The generation counter lets a list fetch
|
|
167
|
+
# that was already in flight recognise that it is stale and not
|
|
168
|
+
# overwrite a fresher list.
|
|
169
|
+
# @return [void]
|
|
170
|
+
def invalidate_tools_cache
|
|
171
|
+
@mutex.synchronize do
|
|
172
|
+
@tools = nil
|
|
173
|
+
@tools_data = nil
|
|
174
|
+
@tools_generation = tools_generation + 1
|
|
175
|
+
end
|
|
176
|
+
# The cached entry (which carries the list too) is stale as well.
|
|
177
|
+
invalidate_cache(:tools)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @return [Integer] the current tool-list generation (bump on invalidation)
|
|
181
|
+
def tools_generation
|
|
182
|
+
@tools_generation ||= 0
|
|
183
|
+
end
|
|
184
|
+
public :tools_generation
|
|
185
|
+
|
|
186
|
+
# Fetch and cache the tool list, re-fetching when the cache was
|
|
187
|
+
# invalidated while the fetch was in flight (bounded).
|
|
188
|
+
# @return [Array<MCPClient::Tool>]
|
|
189
|
+
def fetch_tools_list
|
|
190
|
+
3.times do
|
|
191
|
+
generation = @mutex.synchronize { tools_generation }
|
|
192
|
+
tools_data = request_tools_list
|
|
193
|
+
# MCP 2026-07-28: tools with invalid x-mcp-header annotations are
|
|
194
|
+
# excluded from the list on this transport.
|
|
195
|
+
tools_data = reject_invalid_header_tools(tools_data) if modern?
|
|
196
|
+
tools = tools_data.map { |tool_data| MCPClient::Tool.from_json(tool_data, server: self) }
|
|
197
|
+
stored = store_tools(tools, generation)
|
|
198
|
+
return stored if stored
|
|
199
|
+
end
|
|
200
|
+
raise MCPClient::Errors::TransportError, 'tools/list kept changing while it was being fetched'
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Store a freshly fetched tool list unless the cache was invalidated
|
|
204
|
+
# while it was being fetched, in which case the fresher list wins.
|
|
205
|
+
# @param tools [Array<MCPClient::Tool>] the fetched list
|
|
206
|
+
# @param generation [Integer] tools_generation when the fetch started
|
|
207
|
+
# @return [Array<MCPClient::Tool>] the list to hand to the caller
|
|
208
|
+
def store_tools(tools, generation)
|
|
209
|
+
@mutex.synchronize do
|
|
210
|
+
if tools_generation == generation
|
|
211
|
+
# A copy is kept only when its hint was attached (or the list
|
|
212
|
+
# carried none): a fetch whose entry was cleared or replaced in
|
|
213
|
+
# flight leaves nothing behind, so the next access fetches again.
|
|
214
|
+
previous = @tools
|
|
215
|
+
@tools = attach_list_value(:tools, tools) ? tools : nil
|
|
216
|
+
# A re-fetch that brought different definitions (an expired ttlMs
|
|
217
|
+
# during a tools/call) is a change the host must see: a client
|
|
218
|
+
# re-resolves a tool for post-call validation only when the
|
|
219
|
+
# generation moves, and would otherwise check the result against
|
|
220
|
+
# the definition the call was not answered under.
|
|
221
|
+
@tools_generation = tools_generation + 1 if tool_definitions_changed?(previous, tools)
|
|
222
|
+
return tools
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Invalidated while in flight: this list is stale even if nothing
|
|
226
|
+
# newer was stored yet, and whatever is current may be another
|
|
227
|
+
# request's list — nil makes the caller fetch again.
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Drop the transport's cached list of a kind, so a re-list after a change
|
|
233
|
+
# (or the HeaderMismatch refresh) really fetches the new definitions.
|
|
234
|
+
# @param kind [Symbol] :tools, :prompts, :resources or :templates
|
|
235
|
+
# @return [void]
|
|
236
|
+
def invalidate_list_cache(kind)
|
|
237
|
+
case kind
|
|
238
|
+
when :tools then invalidate_tools_cache
|
|
239
|
+
when :prompts
|
|
240
|
+
@mutex.synchronize do
|
|
241
|
+
@prompts = nil
|
|
242
|
+
@prompts_data = nil
|
|
243
|
+
end
|
|
244
|
+
when :resources
|
|
245
|
+
@mutex.synchronize do
|
|
246
|
+
@resources_result = nil
|
|
247
|
+
@resources_data = nil
|
|
248
|
+
end
|
|
249
|
+
when :templates
|
|
250
|
+
# resources/list_changed covers resources/templates/list too: the
|
|
251
|
+
# old templates are stale, and holding them keeps a list the next
|
|
252
|
+
# fetch will replace alive for the life of the connection.
|
|
253
|
+
@mutex.synchronize { @templates_result = nil }
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Exclude tool definitions whose x-mcp-header annotations violate the
|
|
258
|
+
# transport constraints (MCP 2026-07-28: "Rejection means the client
|
|
259
|
+
# MUST exclude the invalid tool from the result of tools/list"), logging
|
|
260
|
+
# a warning with the tool name and the reason.
|
|
261
|
+
# @param tools_data [Array<Hash>] raw tool definitions
|
|
262
|
+
# @return [Array<Hash>] the acceptable definitions
|
|
263
|
+
def reject_invalid_header_tools(tools_data)
|
|
264
|
+
tools_data.reject do |data|
|
|
265
|
+
schema = data['inputSchema'] || data[:inputSchema] || data['schema'] || data[:schema]
|
|
266
|
+
errors = MCPClient::HeaderParams.validate_schema(schema)
|
|
267
|
+
next false if errors.empty?
|
|
268
|
+
|
|
269
|
+
name = data['name'] || data[:name]
|
|
270
|
+
@logger.warn("Rejecting tool #{sanitize_log_text(name.to_s.inspect)}: invalid x-mcp-header annotation: " \
|
|
271
|
+
"#{sanitize_log_text(errors.join('; '))}")
|
|
272
|
+
true
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|