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,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