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,694 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../request_authorization'
4
+
5
+ module MCPClient
6
+ module HttpTransportBase
7
+ # MCP 2026-07-28 caching support shared by the HTTP transports: serving
8
+ # a stale list on transient re-fetch failures, and keeping privately
9
+ # scoped cache entries within their authorization context.
10
+ module CacheSupport
11
+ # The per-thread record of the Authorization a request went out with,
12
+ # kept exactly as every other transport keeps it.
13
+ include MCPClient::RequestAuthorization
14
+
15
+ # Stand-in app for instantiating middleware whose request hook is run
16
+ # without a request (the freshness probe).
17
+ NOOP_APP = ->(_env) {}
18
+
19
+ # Context that matches no cached entry: the credentials the next
20
+ # request would carry cannot be determined without sending it.
21
+ UNKNOWN_CONTEXT = :unknown
22
+
23
+ # Framework middleware that never puts an Authorization header on a
24
+ # request, whatever literal configuration it was installed with: the
25
+ # probe steps over it instead of running it (some of it overrides
26
+ # `call` and could not be run without sending anyway). A handler that
27
+ # was also handed a host callback is unknown all the same
28
+ # ({#probe_host_callback_free?} is checked first).
29
+ #
30
+ # `Faraday::FollowRedirects::Middleware` is on the list because the gem
31
+ # itself depends on it and a host that installs it should keep its
32
+ # private cache hits: it has no request phase, and the only header it
33
+ # ever touches is the Authorization it *deletes* on a cross-host
34
+ # redirect -- which can cost a hit, never leak one.
35
+ PROBE_AUTHORIZATION_NEUTRAL_MIDDLEWARE = %w[
36
+ Faraday::Retry::Middleware
37
+ Faraday::Request::Retry
38
+ Faraday::Request::Json
39
+ Faraday::Request::UrlEncoded
40
+ Faraday::Request::Multipart
41
+ Faraday::Response::Json
42
+ Faraday::Response::Logger
43
+ Faraday::FollowRedirects::Middleware
44
+ ].freeze
45
+
46
+ # The only middleware the probe runs: Faraday's own Authorization
47
+ # middleware, whose header is a pure function of the arguments it was
48
+ # installed with -- and only when those arguments are literal
49
+ # configuration it can do nothing with but read
50
+ # ({#probe_static_value?}). Host middleware is never run.
51
+ PROBE_PURE_MIDDLEWARE = [Faraday::Request::Authorization].freeze
52
+
53
+ # Configuration a middleware can only read: a literal, or a plain
54
+ # container of literals. Anything else (a proc that vends a fresh
55
+ # token, an object that answers `call`, a holder something else can
56
+ # rotate) may hand a different credential to every request.
57
+ PROBE_STATIC_CLASSES = [NilClass, TrueClass, FalseClass, Numeric, Symbol, String].freeze
58
+
59
+ # How deep a container of configuration is looked into; beyond it the
60
+ # configuration counts as something the probe cannot read.
61
+ PROBE_STATIC_DEPTH = 4
62
+
63
+ # Constructors that build a middleware instance and nothing else. A
64
+ # class with a constructor of its own can give its instances an
65
+ # `on_request` hook (`define_singleton_method`, an extended module),
66
+ # which a test of the class would never see -- so a class-level
67
+ # "no request phase" verdict only holds when Faraday's own constructor
68
+ # is the one that builds the instance.
69
+ PROBE_DEFAULT_INITIALIZE_OWNERS = [Faraday::Middleware, Object, Kernel, BasicObject].freeze
70
+
71
+ # Faraday env keys the {AuthorizationRecorder} files one exchange's
72
+ # record under. Both belong to that exchange and to no other, which a
73
+ # thread-local cannot express: a request the host's response phase
74
+ # nests inside this one runs on this very thread and would overwrite
75
+ # whatever this exchange left there.
76
+ SENT_AUTHORIZATION_KEY = :mcp_client_sent_authorization
77
+ RESPONSE_RECEIVED_AT_KEY = :mcp_client_response_received_at
78
+
79
+ # Innermost middleware on the JSON-RPC connection: it records the
80
+ # Authorization a request carries once every host middleware
81
+ # (faraday_config) has run, right before the adapter sends it, and the
82
+ # moment the response came back, before any host `on_complete` had it.
83
+ # A request that then times out or fails to connect still has a known
84
+ # context, so the stale copy of that context may be served.
85
+ class AuthorizationRecorder < Faraday::Middleware
86
+ def initialize(app, transport)
87
+ super(app)
88
+ @transport = transport
89
+ end
90
+
91
+ def on_request(env)
92
+ @transport.send(:note_request_authorization,
93
+ MCPClient::ResultCaching.authorization_header_value(env.request_headers))
94
+ env[SENT_AUTHORIZATION_KEY] = @transport.send(:recorded_request_authorization)
95
+ # Each attempt the retry middleware makes is received on its own.
96
+ env[RESPONSE_RECEIVED_AT_KEY] = nil
97
+ stamp_response_chunk(env)
98
+ end
99
+
100
+ # The bytes are in hand and nothing of the host's has run on them
101
+ # yet: the TTL of whatever this response carries starts here (MCP
102
+ # 2026-07-28 caching, "Freshness Calculation") -- or already started,
103
+ # when the response was read as a stream.
104
+ def on_complete(env)
105
+ env[RESPONSE_RECEIVED_AT_KEY] ||= @transport.send(:monotonic_now)
106
+ end
107
+
108
+ private
109
+
110
+ # A response read as a stream is dated from the chunk that completed
111
+ # the result -- not from the keep-alive or progress notification that
112
+ # opened the stream, which is not the result and would expire a slow
113
+ # one before it arrived, and not from whatever the host made of the
114
+ # stream on the way: the chunk's arrival is taken before the
115
+ # transport's own on_data handler dispatches what it carries, and the
116
+ # stamp goes on once that handler flagged the answer as in hand (see
117
+ # StreamRecovery::ResponseBodyCapture#note_response_arrival). A body
118
+ # that is not a stream of events is dated on completion.
119
+ # @param env [Faraday::Env] the outgoing request environment
120
+ # @return [void]
121
+ def stamp_response_chunk(env)
122
+ inner = env.request.on_data
123
+ state = env.request.context
124
+ return unless inner && state.is_a?(Hash)
125
+
126
+ env.request.on_data = lambda do |chunk, size, data_env|
127
+ arrived = @transport.send(:monotonic_now)
128
+ inner.call(chunk, size, data_env)
129
+ env[RESPONSE_RECEIVED_AT_KEY] ||= arrived if state[:mcp_response_seen]
130
+ end
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ # Append the {AuthorizationRecorder} after the host's middleware.
137
+ # @param conn [Faraday::Connection]
138
+ # @return [Faraday::Connection]
139
+ def record_sent_authorization(conn)
140
+ conn.builder.use(AuthorizationRecorder, self)
141
+ @authorization_recorder_installed = true
142
+ conn
143
+ rescue StandardError => e
144
+ # A host that built its own middleware stack locked it, and nothing
145
+ # can be added any more. Private reuse stays off rather than falling
146
+ # back to a guess: see {#sent_authorization_known?}.
147
+ @logger.debug("Could not install the Authorization recorder middleware: #{e.class}")
148
+ @authorization_recorder_installed = false
149
+ conn
150
+ end
151
+
152
+ # Whether what a request on this connection went out with is known.
153
+ #
154
+ # A connection carrying no host middleware answers for itself: the
155
+ # transport applies the headers and nothing else touches them. Once a
156
+ # `faraday_config` block is in play only the recorder, installed
157
+ # innermost, sees what the adapter is handed -- host middleware may put
158
+ # an Authorization on the request afterwards, and take it off the
159
+ # response environment again before anything reads it back. Without the
160
+ # recorder there is nothing to trust, and a result whose credentials
161
+ # are unknown is not an anonymous result (MCP 2026-07-28 caching:
162
+ # private results "MUST NOT be shared across authorization contexts").
163
+ # @return [Boolean]
164
+ def sent_authorization_known?
165
+ return true unless @faraday_config
166
+
167
+ @authorization_recorder_installed && request_authorization_recorded?
168
+ end
169
+
170
+ # Re-fetch a list that has gone stale, or serve the stale copy when the
171
+ # re-fetch fails for a transient reason ("Clients MAY serve stale
172
+ # responses if errors occur during re-fetching").
173
+ # @param kind [Symbol] the list kind (for the log line)
174
+ # @param cached [MCPClient::CachedResult, nil] the entry captured before the re-fetch
175
+ # @yield performs the fetch
176
+ # @return [Object] the fresh value, or the captured entry's value on a transient failure
177
+ def refetch_or_serve_stale(kind, cached)
178
+ # The marker of the previous request on this thread must not stand
179
+ # in for an attempt that fails before it applies its own headers.
180
+ Thread.current[request_authorization_key] = UNRECORDED_AUTHORIZATION
181
+ note_request_params_pending
182
+ yield
183
+ rescue MCPClient::Errors::TransientServerError, MCPClient::Errors::ConnectionError,
184
+ MCPClient::Errors::TransportError => e
185
+ # A revoked or insufficient authorization is not a transient failure:
186
+ # serving the stale list would hide it from the host's auth flow. The
187
+ # stale copy is judged against the credentials the failed request
188
+ # went out with (they may have changed since the copy was made); an
189
+ # attempt that never got that far has no private fallback.
190
+ context = request_authorization_recorded? ? request_authorization_context : :unknown
191
+ stale = stale_fallback_for(kind, cached, context: context)
192
+ raise if stale.nil? || authorization_failure?(e)
193
+
194
+ @logger.warn("Re-fetching #{kind} failed (#{e.class}); serving the stale cached list")
195
+ stale
196
+ end
197
+
198
+ # A request that failed: the context it is judged by is the one its
199
+ # request went out with.
200
+ #
201
+ # With the recorder on the connection that is what it filed against
202
+ # this exchange, before the adapter sent the request -- and it stands
203
+ # again here over anything a request the host's response phase nested
204
+ # inside this one left on the thread. The error's own copy of the
205
+ # request headers is not read: a host `on_complete` that redacts
206
+ # Authorization runs before `raise_error` builds its exception, and an
207
+ # authenticated failure would read as anonymous -- serving the
208
+ # anonymous context's private stale copy to whoever holds the
209
+ # credentials now (MCP 2026-07-28 caching: private results "MUST NOT
210
+ # be shared across authorization contexts").
211
+ #
212
+ # Without the recorder, Faraday errors raised by the middleware keep
213
+ # the request headers, which name what was sent as far as anything
214
+ # can tell; otherwise the context stays unknown.
215
+ # @param error [Exception]
216
+ # @return [void]
217
+ def note_failed_request_authorization(error)
218
+ return unless @faraday_config
219
+ return restore_own_exchange_authorization if @authorization_recorder_installed
220
+
221
+ response = error.respond_to?(:response) ? error.response : nil
222
+ headers = response.is_a?(Hash) ? response.dig(:request, :headers) : nil
223
+ return unless headers.respond_to?(:[])
224
+
225
+ note_request_authorization(authorization_header_value(headers))
226
+ end
227
+
228
+ # Put back what this exchange's own request was recorded with, straight
229
+ # into the slot: written through {#file_request_authorization} it would
230
+ # become the enclosing exchange's record too.
231
+ # @return [void]
232
+ def restore_own_exchange_authorization
233
+ record = Thread.current[exchange_records_key]&.last&.first
234
+ Thread.current[request_authorization_key] = record unless record.nil?
235
+ end
236
+
237
+ # @param kind [Symbol, String, nil] the cache kind whose next request is modelled
238
+ # (:tools, :prompts, :resources, :templates, :discover or "read:<uri>")
239
+ # @return [String, nil, Symbol] the Authorization header the next request of that
240
+ # operation would carry, or {UNKNOWN_CONTEXT} when host middleware makes that
241
+ # impossible to tell
242
+ def current_authorization_context(kind = nil)
243
+ # The probe starts from the configured headers, as a real request
244
+ # does: a provider without a token leaves a static header in place.
245
+ # They are held in Faraday's own case-insensitive table, so the
246
+ # provider's `Authorization` replaces a header configured under any
247
+ # other spelling instead of being read past by the lookup below.
248
+ base = probe_base_headers
249
+ return UNKNOWN_CONTEXT if base.nil?
250
+
251
+ probe = HeaderProbe.new(base)
252
+ @oauth_provider&.apply_authorization(probe)
253
+ headers = probe.headers
254
+ if @faraday_config
255
+ # Faraday middleware installed by the host (faraday_config) may
256
+ # add or replace the header after the request block ran. Host
257
+ # middleware is never run to find out what it would put there --
258
+ # running it spends whatever one-time credential it vends, and no
259
+ # inspection can tell which middleware does that -- so the answer
260
+ # is unknown for any stack that is not framework middleware the
261
+ # transport can read off its own configuration.
262
+ headers = middleware_request_headers(headers, *probe_request_for(kind))
263
+ return UNKNOWN_CONTEXT if headers.nil?
264
+ end
265
+ authorization_fingerprint(authorization_header_value(headers))
266
+ end
267
+
268
+ # The headers a request starts from, before the OAuth provider and
269
+ # before any middleware: the transport's own configured headers, laid
270
+ # over whatever the connection itself carries.
271
+ #
272
+ # A `faraday_config` block may set or mutate `conn.headers` — including
273
+ # `Authorization` — and every request Faraday builds on that connection
274
+ # starts from that table, so a probe that read only `@headers` would
275
+ # answer "anonymous" for requests that go out with a bearer. Reading a
276
+ # built connection's header table executes no middleware and runs no
277
+ # host code: the block has already run (the same connection sends the
278
+ # real requests), and the table is plain configuration.
279
+ # @return [Faraday::Utils::Headers, nil] nil when the connection could
280
+ # carry an authorization the probe cannot see
281
+ def probe_base_headers
282
+ headers = faraday_headers(@headers)
283
+ return headers unless @faraday_config
284
+
285
+ # `@headers` wins over the connection's table, exactly as it does on
286
+ # the wire ({#apply_request_headers} applies it per request).
287
+ base = faraday_headers(http_connection.headers)
288
+ headers.each { |key, value| base[key] = value }
289
+ base
290
+ rescue StandardError => e
291
+ @logger.debug("Could not read the Authorization configured on the connection: #{e.class}")
292
+ nil
293
+ end
294
+
295
+ # The JSON-RPC request the probe models for a cache kind: the very
296
+ # operation whose cache is being checked, so middleware that chooses
297
+ # credentials by method or body answers as it would for that request.
298
+ # @param kind [Symbol, String, nil]
299
+ # @return [Array(String, Hash)] method and params
300
+ def probe_request_for(kind)
301
+ case kind
302
+ when :tools then ['tools/list', {}]
303
+ when :prompts then ['prompts/list', {}]
304
+ when :resources then ['resources/list', {}]
305
+ when :templates then ['resources/templates/list', {}]
306
+ when :discover then ['server/discover', {}]
307
+ when /\Aread:(.+)\z/m then ['resources/read', { 'uri' => Regexp.last_match(1) }]
308
+ else [@probe_method || 'ping', {}]
309
+ end
310
+ end
311
+
312
+ # Minimal request stand-in for asking the OAuth provider which
313
+ # Authorization header it would apply.
314
+ HeaderProbe = Struct.new(:headers)
315
+
316
+ # The headers a request of this operation would go out with, once the
317
+ # connection's middleware had its say -- without sending anything and
318
+ # without running a single line of the host's own code.
319
+ #
320
+ # A `faraday_config` block may install middleware that vends a
321
+ # credential of its own, and four review rounds of reflection could not
322
+ # tell such middleware apart from an inert one: the rotating state can
323
+ # sit in a constant, a global, a thread-local or the binding of a
324
+ # method, none of which building a copy leaves behind. So the probe no
325
+ # longer tries: it steps over framework middleware that sets no
326
+ # Authorization header, runs framework middleware whose header is a
327
+ # pure function of the literal configuration it was installed with
328
+ # ({PROBE_PURE_MIDDLEWARE}), and answers nil -- an unknown context, in
329
+ # which no private entry is served -- for anything else.
330
+ # @param headers [Hash] the headers before middleware
331
+ # @param method [String] the JSON-RPC method the probe models
332
+ # @param params [Hash] its params
333
+ # @return [Hash, nil] the headers after middleware, or nil when they cannot be determined
334
+ def middleware_request_headers(headers, method = @probe_method || 'ping', params = {})
335
+ conn = http_connection
336
+ pure = []
337
+ conn.builder.handlers.each do |handler|
338
+ case probe_handler_class(handler)
339
+ when :neutral then next
340
+ when :pure then pure << handler
341
+ else return nil
342
+ end
343
+ end
344
+ # Nothing on the stack can change the header: no request is modelled
345
+ # at all, so the host's request_meta is not read for one either.
346
+ return headers if pure.empty?
347
+
348
+ env = probe_request_env(conn, headers, method, params)
349
+ pure.each { |handler| handler.build(NOOP_APP).on_request(env) }
350
+ env.request_headers
351
+ rescue StandardError => e
352
+ @logger.debug("Could not tell which Authorization header a request would carry: #{e.class}")
353
+ nil
354
+ end
355
+
356
+ # What one handler on the connection is, as far as the probe can tell
357
+ # without running a line of the host's code:
358
+ #
359
+ # * `:neutral` — it cannot change what a request carries: the probe's
360
+ # own recorder, framework middleware that never sets an Authorization
361
+ # header ({PROBE_AUTHORIZATION_NEUTRAL_MIDDLEWARE}) or middleware with
362
+ # no request phase at all;
363
+ # * `:pure` — framework middleware whose header is a pure function of
364
+ # the literal configuration it was installed with, which the probe may
365
+ # therefore run ({PROBE_PURE_MIDDLEWARE});
366
+ # * `:unknown` — anything else, including every handler carrying a
367
+ # callback the host supplied: Faraday hands it the mutable request
368
+ # env, and nothing about the middleware class it was given to says
369
+ # what the request would then carry, or contain.
370
+ # @param handler [Faraday::RackBuilder::Handler]
371
+ # @return [Symbol] :neutral, :pure or :unknown
372
+ def probe_handler_class(handler)
373
+ # The transport's own handlers cannot change what a request carries.
374
+ return :neutral if [AuthorizationRecorder, StreamRecovery::ResponseBodyCapture].include?(handler.klass)
375
+ return :unknown unless probe_host_callback_free?(handler)
376
+ return :neutral if PROBE_AUTHORIZATION_NEUTRAL_MIDDLEWARE.include?(handler.klass.name)
377
+ return :neutral if probe_response_only_middleware?(handler.klass)
378
+
379
+ probe_pure_middleware?(handler) ? :pure : :unknown
380
+ end
381
+
382
+ # Whether the JSON-RPC body this transport builds is the body the server
383
+ # answers. A `faraday_config` block may install middleware that rewrites
384
+ # it — a locale, a tenant, a nonce written into `params._meta` — and the
385
+ # effective parameters a result is bound to would then describe a
386
+ # request that was never sent.
387
+ # @return [Boolean]
388
+ def probe_request_body_faithful?
389
+ return true unless @faraday_config
390
+
391
+ http_connection.builder.handlers.all? { |handler| probe_body_neutral_handler?(handler) }
392
+ rescue StandardError => e
393
+ @logger.debug("Could not tell whether a request reaches the server as built: #{e.class}")
394
+ false
395
+ end
396
+
397
+ # Whether one handler provably leaves the JSON-RPC body alone: the
398
+ # probe's own recorder, framework middleware that touches no body
399
+ # ({PROBE_AUTHORIZATION_NEUTRAL_MIDDLEWARE}), middleware with no request
400
+ # phase at all, or {PROBE_PURE_MIDDLEWARE} — whose request hook writes
401
+ # one header and never hands its configuration the env.
402
+ #
403
+ # What such a handler was configured with does not matter here, which is
404
+ # where this parts company with the Authorization question: a credential
405
+ # that rotates changes the header a request carries, never the
406
+ # parameters the server answers. A handler carrying a host callback is
407
+ # unknown all the same — Faraday hands it the mutable env, body included.
408
+ # @param handler [Faraday::RackBuilder::Handler]
409
+ # @return [Boolean]
410
+ def probe_body_neutral_handler?(handler)
411
+ # The transport's own handlers: the recorder reads a request's
412
+ # Authorization, and the capture buffers a response's body -- neither
413
+ # changes the parameters the server answers.
414
+ return true if [AuthorizationRecorder, StreamRecovery::ResponseBodyCapture].include?(handler.klass)
415
+ return true if PROBE_PURE_MIDDLEWARE.include?(handler.klass)
416
+ return false unless probe_host_callback_free?(handler)
417
+
418
+ PROBE_AUTHORIZATION_NEUTRAL_MIDDLEWARE.include?(handler.klass.name) ||
419
+ probe_response_only_middleware?(handler.klass)
420
+ end
421
+
422
+ # The effective parameters the next request would carry, or
423
+ # {MCPClient::RequestMetadata::OPAQUE_PARAMS} when host middleware may
424
+ # rewrite its body: no fingerprint then describes the request the server
425
+ # would answer, so no cached result is served for it — whatever its
426
+ # `cacheScope`, since sharing across callers is not sharing across
427
+ # result-affecting parameters (MCP 2026-07-28 server/utilities/caching).
428
+ # @return [String, Symbol]
429
+ def current_params_fingerprint
430
+ probe_request_body_faithful? ? super : MCPClient::RequestMetadata::OPAQUE_PARAMS
431
+ end
432
+
433
+ # @return [String, Symbol, nil] the parameters of the request this
434
+ # thread last sent, opaque for the same reason
435
+ def request_params_fingerprint
436
+ probe_request_body_faithful? ? super : MCPClient::RequestMetadata::OPAQUE_PARAMS
437
+ end
438
+
439
+ # The Faraday env of a request shaped like the real JSON-RPC POST of
440
+ # the operation (endpoint, JSON body with its method and params),
441
+ # which is never sent: the framework middleware the probe runs sees
442
+ # the request a real one would.
443
+ # @param conn [Faraday::Connection]
444
+ # @param headers [Hash] the headers before middleware
445
+ # @param method [String] the JSON-RPC method the probe models
446
+ # @param params [Hash] its params
447
+ # @return [Faraday::Env]
448
+ def probe_request_env(conn, headers, method, params)
449
+ request = conn.build_request(:post) do |req|
450
+ req.url(@endpoint)
451
+ headers.each { |k, v| req.headers[k] = v }
452
+ req.headers['Content-Type'] = 'application/json'
453
+ # The body a real request would carry, its effective `_meta`
454
+ # included (host request_meta, protocol fields).
455
+ probe = build_jsonrpc_request(method, params, 0, note: false)
456
+ req.body = JSON.generate(probe)
457
+ # The routing headers a real modern POST carries.
458
+ modern_request_headers(probe).each { |k, v| req.headers[k] = v } if modern?
459
+ end
460
+ request.to_env(conn)
461
+ end
462
+
463
+ # Whether a middleware has no request phase at all, so nothing it does
464
+ # can change what a request carries: Faraday's own `call` runs
465
+ # `on_request` and then the response phase, which a probe that sends
466
+ # nothing never reaches. This is the shape of the class, not a guess
467
+ # about what its code does, and no host code runs to establish it.
468
+ # @param klass [Class] a middleware class on the connection
469
+ # @return [Boolean]
470
+ def probe_response_only_middleware?(klass)
471
+ return false if klass.method_defined?(:on_request) || klass.private_method_defined?(:on_request)
472
+ return false unless klass.method_defined?(:call) && klass.instance_method(:call).owner == Faraday::Middleware
473
+
474
+ probe_default_construction?(klass)
475
+ end
476
+
477
+ # @param klass [Class] a middleware class on the connection
478
+ # @return [Boolean] whether its instances come out of Faraday's own constructor
479
+ def probe_default_construction?(klass)
480
+ return false unless PROBE_DEFAULT_INITIALIZE_OWNERS.include?(klass.instance_method(:initialize).owner)
481
+
482
+ klass.method(:new).owner == Class
483
+ end
484
+
485
+ # Whether a handler installs framework middleware the probe may run:
486
+ # one of {PROBE_PURE_MIDDLEWARE} (exactly, never a subclass of it),
487
+ # configured with nothing but literal configuration. Its constructor
488
+ # can then take nothing to spend and its request hook can vend nothing
489
+ # but what it was handed. This is stricter than
490
+ # {#probe_host_callback_free?}: a holder something else can rotate is
491
+ # nothing a middleware may *run*, but it is still a credential that
492
+ # can differ from request to request.
493
+ # @param handler [Faraday::RackBuilder::Handler]
494
+ # @return [Boolean]
495
+ def probe_pure_middleware?(handler)
496
+ return false unless PROBE_PURE_MIDDLEWARE.include?(handler.klass)
497
+
498
+ args = handler.instance_variable_get(:@args)
499
+ kwargs = handler.instance_variable_get(:@kwargs) || {}
500
+ return false unless args.is_a?(Array) && kwargs.is_a?(Hash)
501
+
502
+ (args + kwargs.to_a.flatten(1)).all? { |arg| probe_static_value?(arg) }
503
+ end
504
+
505
+ # Whether a handler carries no host code of its own: no configuration
506
+ # block, and no argument the middleware it was given to could call.
507
+ #
508
+ # A logger formatter, a redirect callback, a class the middleware
509
+ # instantiates -- all of it is host code that the middleware hands the
510
+ # mutable request env, and the middleware class it was given to says
511
+ # nothing about what it will do with it. A handler that carries one is
512
+ # an unknown context however inert its class looks; a logger *sink*,
513
+ # which only ever receives strings, is configuration like any other.
514
+ # @param handler [Faraday::RackBuilder::Handler]
515
+ # @return [Boolean]
516
+ def probe_host_callback_free?(handler)
517
+ return false if handler.instance_variable_get(:@block)
518
+
519
+ args = handler.instance_variable_get(:@args)
520
+ kwargs = handler.instance_variable_get(:@kwargs) || {}
521
+ return false unless args.is_a?(Array) && kwargs.is_a?(Hash)
522
+
523
+ (args + kwargs.to_a.flatten(1)).none? { |arg| probe_host_callback?(arg) }
524
+ end
525
+
526
+ # @param value [Object] an argument a middleware was installed with
527
+ # @param depth [Integer]
528
+ # @return [Boolean] whether it is (or contains) code the middleware could run
529
+ def probe_host_callback?(value, depth = 0)
530
+ return true if value.is_a?(Proc) || value.is_a?(Method) || value.is_a?(Module) || value.respond_to?(:call)
531
+ return false unless value.is_a?(Hash) || value.is_a?(Array)
532
+ # Past the depth a container is looked into, what it holds is
533
+ # unknown -- and unknown counts as host code.
534
+ return true unless depth < PROBE_STATIC_DEPTH
535
+
536
+ members = value.is_a?(Hash) ? value.flat_map { |k, v| [k, v] } : value
537
+ members.any? { |member| probe_host_callback?(member, depth + 1) }
538
+ end
539
+
540
+ # @param value [Object] an argument a middleware was installed with
541
+ # @param depth [Integer]
542
+ # @return [Boolean] whether it is configuration and nothing else
543
+ def probe_static_value?(value, depth = 0)
544
+ return true if PROBE_STATIC_CLASSES.any? { |klass| value.is_a?(klass) }
545
+ return false unless value.is_a?(Hash) || value.is_a?(Array)
546
+ return false unless depth < PROBE_STATIC_DEPTH
547
+
548
+ members = value.is_a?(Hash) ? value.flat_map { |k, v| [k, v] } : value
549
+ members.all? { |member| probe_static_value?(member, depth + 1) }
550
+ end
551
+
552
+ # Whether a fetch brought tool definitions other than the ones it
553
+ # replaced. A first fetch replaces nothing, so it announces no change.
554
+ # @param previous [Array<MCPClient::Tool>, nil] the list held before the fetch
555
+ # @param tools [Array<MCPClient::Tool>] the freshly fetched list
556
+ # @return [Boolean]
557
+ def tool_definitions_changed?(previous, tools)
558
+ return false if previous.nil?
559
+
560
+ tool_definitions(previous) != tool_definitions(tools)
561
+ end
562
+
563
+ # @param tools [Array<MCPClient::Tool>]
564
+ # @return [Array<Array>] what a caller of the list can act on
565
+ def tool_definitions(tools)
566
+ tools.map { |t| [t.name, t.description, t.schema, t.output_schema, t.annotations] }
567
+ end
568
+
569
+ # Send a JSON-RPC request and parse its response, keeping the result
570
+ # bound to its own request.
571
+ #
572
+ # A request nested inside this one runs on this very thread and records
573
+ # credentials and parameters of its own: parsing an SSE-framed response
574
+ # dispatches the notifications it carries, and host middleware may send
575
+ # a request from the response phase, before this exchange has bound its
576
+ # result or failed. The exchange therefore holds its own record
577
+ # throughout ({MCPClient::RequestAuthorization#recording_one_exchange})
578
+ # and puts back the parameters its request was built with — however it
579
+ # ends, so a failed re-fetch is judged by what it carried itself
580
+ # (MCP 2026-07-28 caching).
581
+ # @param request [Hash] the JSON-RPC request
582
+ # @return [Object] the parsed result
583
+ def exchange_jsonrpc(request, timeout: nil, deadline: nil, extra_headers: {})
584
+ sent_params = recorded_request_params
585
+ recording_one_exchange do
586
+ perform_jsonrpc_exchange(request, timeout: timeout, deadline: deadline, extra_headers: extra_headers)
587
+ ensure
588
+ restore_request_params(sent_params)
589
+ end
590
+ end
591
+
592
+ # One exchange, from the POST to the parsed result.
593
+ # @param request [Hash] the JSON-RPC request
594
+ # @return [Object] the parsed result
595
+ def perform_jsonrpc_exchange(request, timeout: nil, deadline: nil, extra_headers: {})
596
+ clear_response_received_at if respond_to?(:clear_response_received_at, true)
597
+ sent_at = monotonic_now if respond_to?(:monotonic_now, true)
598
+ response = send_http_request(request, timeout: timeout, deadline: deadline, extra_headers: extra_headers)
599
+ # When the innermost middleware had the response, not when the host's
600
+ # response phase was finished with it.
601
+ received_at = response_receipt_time(response, sent_at)
602
+ # The credentials this exchange's own request went out with, as the
603
+ # recorder filed them against it: a request the response phase nested
604
+ # inside this one left its own on this thread.
605
+ restore_recorded_request_authorization(response)
606
+ # A connection that recorded nothing of its own still names the
607
+ # credentials it sent in the response's environment.
608
+ note_sent_authorization(response)
609
+ # Taken before the parse: a notification callback may nest one too.
610
+ sent_authorization = recorded_request_authorization
611
+ begin
612
+ result = parse_response(response, request)
613
+ ensure
614
+ # The outer request's own context, whatever a notification the
615
+ # response carried did on this thread.
616
+ restore_request_authorization(sent_authorization)
617
+ end
618
+ note_response_received_at(received_at) if received_at && respond_to?(:note_response_received_at, true)
619
+ result
620
+ end
621
+
622
+ # @param response [Faraday::Response, nil]
623
+ # @return [Faraday::Env, nil] the environment the exchange ran in
624
+ def response_env(response)
625
+ env = response.respond_to?(:env) ? response.env : nil
626
+ env.respond_to?(:[]) ? env : nil
627
+ rescue StandardError
628
+ nil
629
+ end
630
+
631
+ # The moment the response was received: what the {AuthorizationRecorder}
632
+ # stamped on the way back in, before a host `on_complete` — which may
633
+ # log, convert, retry or send a request of its own — had the response.
634
+ #
635
+ # A connection carrying no recorder (a host that locked its own stack)
636
+ # has nothing stamped, and by the time the transport has the response
637
+ # the host's response phase has run for as long as it took: the TTL
638
+ # then runs from the moment the request was sent, the latest moment
639
+ # known not to be after receipt, so that a result is never held fresh
640
+ # past "t_received + ttlMs" (MCP 2026-07-28 caching, "Freshness
641
+ # Calculation").
642
+ # @param response [Faraday::Response, nil]
643
+ # @param sent_at [Float, nil] when the request was sent
644
+ # @return [Float, nil]
645
+ def response_receipt_time(response, sent_at = nil)
646
+ stamped = response_env(response)&.[](RESPONSE_RECEIVED_AT_KEY)
647
+ return stamped if stamped.is_a?(Numeric)
648
+
649
+ sent_at || (monotonic_now if respond_to?(:monotonic_now, true))
650
+ end
651
+
652
+ # Put back what this exchange's own request was recorded with, over
653
+ # anything a nested request left on this thread since.
654
+ # @param response [Faraday::Response, nil]
655
+ # @return [void]
656
+ def restore_recorded_request_authorization(response)
657
+ record = response_env(response)&.[](SENT_AUTHORIZATION_KEY)
658
+ restore_request_authorization(record) unless record.nil?
659
+ end
660
+
661
+ # Remember the Authorization a request actually carried once it was
662
+ # sent, for a connection that recorded none of its own.
663
+ #
664
+ # What binds the result is what the {AuthorizationRecorder} saw
665
+ # immediately before the adapter sent the request. `response.env` is a
666
+ # mutable structure the response phase may rewrite — a host `on_complete`
667
+ # that redacts `Authorization` for logging, a redirect handler that
668
+ # strips it — and reading it back afterwards would file an authenticated
669
+ # result under the anonymous context, where the next anonymous request
670
+ # would be served Alice's private data. So the env answers only when
671
+ # nothing was recorded at all -- and what it answers binds nothing on a
672
+ # connection where the recorder could not be installed, which is the one
673
+ # case where a host's response phase had the environment first
674
+ # ({#sent_authorization_known?}).
675
+ # @param response [Faraday::Response, nil]
676
+ # @return [void]
677
+ def note_sent_authorization(response)
678
+ return if request_authorization_recorded?
679
+
680
+ env = response.respond_to?(:env) ? response.env : nil
681
+ return unless env.respond_to?(:request_headers) && env.request_headers
682
+
683
+ note_request_authorization(authorization_header_value(env.request_headers))
684
+ end
685
+
686
+ # @param error [Exception] a failure raised by the HTTP pipeline
687
+ # @return [Boolean] whether it reports an authorization failure (401/403)
688
+ def authorization_failure?(error)
689
+ error.is_a?(MCPClient::Errors::InsufficientScopeError) ||
690
+ (error.is_a?(MCPClient::Errors::ConnectionError) && error.message.start_with?('Authorization failed'))
691
+ end
692
+ end
693
+ end
694
+ end