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