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,999 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
require_relative 'cached_result'
|
|
6
|
+
|
|
7
|
+
module MCPClient
|
|
8
|
+
# Freshness bookkeeping for cacheable results (MCP 2026-07-28
|
|
9
|
+
# server/utilities/caching), shared by every transport: hints recorded per
|
|
10
|
+
# operation (:discover, :tools, :prompts, :resources, :templates and
|
|
11
|
+
# per-URI reads), freshness checks, invalidation on change notifications,
|
|
12
|
+
# and the rule that multi round-trip retry results are never cached.
|
|
13
|
+
module ResultCaching
|
|
14
|
+
# Operation kinds with a list cache and the notification that invalidates them.
|
|
15
|
+
LIST_CHANGE_NOTIFICATIONS = {
|
|
16
|
+
'notifications/tools/list_changed' => %i[tools],
|
|
17
|
+
'notifications/prompts/list_changed' => %i[prompts],
|
|
18
|
+
# resources/list_changed also covers resources/templates/list.
|
|
19
|
+
'notifications/resources/list_changed' => %i[resources templates]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Guards the lazy creation of the per-transport cache structures, which
|
|
23
|
+
# request threads and notification threads may touch first.
|
|
24
|
+
CACHE_INIT_LOCK = Mutex.new
|
|
25
|
+
|
|
26
|
+
# Kinds whose cached list lives in the entry itself: a hint recorded for
|
|
27
|
+
# one of them without its list (the list is still being converted, or
|
|
28
|
+
# its conversion failed) is not a cache that may be served.
|
|
29
|
+
LIST_VALUE_KINDS = %i[tools prompts resources templates].freeze
|
|
30
|
+
|
|
31
|
+
# Every list kind that gets a stale placeholder when the cache is
|
|
32
|
+
# cleared, recorded or not, so a copy stored while the clear happened is
|
|
33
|
+
# never served as an unhinted (legacy) list.
|
|
34
|
+
PLACEHOLDER_KINDS = %i[tools prompts resources templates].freeze
|
|
35
|
+
|
|
36
|
+
# The served-entry identity of a list that carried no cache hint at all
|
|
37
|
+
# (a legacy server): nothing was recorded, and nothing was rejected.
|
|
38
|
+
LEGACY_ENTRY = :legacy
|
|
39
|
+
|
|
40
|
+
# How many resources/read results are kept at once: iterating many
|
|
41
|
+
# resources must not grow memory with every URI ever read.
|
|
42
|
+
MAX_CACHED_READS = 64
|
|
43
|
+
|
|
44
|
+
# How many per-URI invalidation generations are kept: a server varying
|
|
45
|
+
# the URI of notifications/resources/updated cannot grow the map without
|
|
46
|
+
# bound — past this, every read counts as invalidated at once instead.
|
|
47
|
+
MAX_READ_GENERATIONS = 256
|
|
48
|
+
|
|
49
|
+
# Paginated list methods and their cache kind.
|
|
50
|
+
LIST_METHOD_KINDS = {
|
|
51
|
+
'tools/list' => :tools,
|
|
52
|
+
'prompts/list' => :prompts,
|
|
53
|
+
'resources/list' => :resources,
|
|
54
|
+
'resources/templates/list' => :templates
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
# @return [Float] monotonic clock, in seconds (stubbed in tests)
|
|
58
|
+
def monotonic_now
|
|
59
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Transports note the moment a response's bytes were in hand, before the
|
|
63
|
+
# notifications it carried are dispatched (a callback may run long, or
|
|
64
|
+
# send a nested request on this thread): the TTL runs from receipt (MCP
|
|
65
|
+
# 2026-07-28 caching, "Freshness Calculation"), not from the end of that
|
|
66
|
+
# processing. The value is per thread and per transport, consumed once.
|
|
67
|
+
# @param now [Float] the monotonic receipt time
|
|
68
|
+
# @return [void]
|
|
69
|
+
def note_response_received_at(now = monotonic_now)
|
|
70
|
+
Thread.current[response_received_key] = now
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Forget a receipt time a request path is about to replace.
|
|
74
|
+
# @return [void]
|
|
75
|
+
def clear_response_received_at
|
|
76
|
+
Thread.current[response_received_key] = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The receipt time of the response this thread just got, or the current
|
|
80
|
+
# time when none was noted (a stubbed transport) or the noted one is older
|
|
81
|
+
# than the request that asks (a leftover from an earlier request).
|
|
82
|
+
# @param since [Float, nil] when the consuming request started
|
|
83
|
+
# @return [Float]
|
|
84
|
+
def response_received_at(since: nil)
|
|
85
|
+
noted = Thread.current[response_received_key]
|
|
86
|
+
Thread.current[response_received_key] = nil
|
|
87
|
+
return monotonic_now unless noted
|
|
88
|
+
return monotonic_now if since && noted < since
|
|
89
|
+
|
|
90
|
+
noted
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Symbol] this transport's thread-local key for the receipt time
|
|
94
|
+
def response_received_key
|
|
95
|
+
:"mcp_response_received_at_#{object_id}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Hash{Object => MCPClient::CachedResult}]
|
|
99
|
+
def cache_entries
|
|
100
|
+
@cache_entries || CACHE_INIT_LOCK.synchronize { @cache_entries ||= {} }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Mutex]
|
|
104
|
+
def cache_entries_mutex
|
|
105
|
+
@cache_entries_mutex || CACHE_INIT_LOCK.synchronize { @cache_entries_mutex ||= Mutex.new }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The invalidation generation of one cache key, so a response that was
|
|
109
|
+
# in flight while its own entry was invalidated is not written back —
|
|
110
|
+
# while an invalidation of another key (a resource updated during a
|
|
111
|
+
# tools/list) leaves it alone. Every key shares a base bumped when the
|
|
112
|
+
# whole cache goes (cleanup, a new authorization context); each key keeps
|
|
113
|
+
# its own count, and every read shares one more.
|
|
114
|
+
#
|
|
115
|
+
# The three are compared side by side rather than added up: a cleanup
|
|
116
|
+
# bumps the base *and* clears the other counts, so a sum would carry a
|
|
117
|
+
# key an invalidation had already bumped (0 + 1) straight through the
|
|
118
|
+
# cleanup unchanged (1 + 0), and the response of a request the cleanup
|
|
119
|
+
# overtook would install itself as fresh.
|
|
120
|
+
# @param key [Symbol, String, nil] the cache key; nil for the base alone
|
|
121
|
+
# @return [Array<Integer>] an identity, only ever compared for equality
|
|
122
|
+
def cache_epoch(key = nil)
|
|
123
|
+
cache_entries_mutex.synchronize { cache_generation(key) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param method [String] a list method, e.g. 'tools/list'
|
|
127
|
+
# @return [Array<Integer>] the generation of that list's cache key
|
|
128
|
+
def list_cache_epoch(method)
|
|
129
|
+
cache_epoch(list_kind_for(method))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param method [String] a list method, e.g. 'tools/list'
|
|
133
|
+
# @return [Symbol, nil] the cache kind that list fills
|
|
134
|
+
def list_kind_for(method)
|
|
135
|
+
LIST_METHOD_KINDS[method]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Array<Integer>] (call while holding cache_entries_mutex)
|
|
139
|
+
def cache_generation(key)
|
|
140
|
+
base = @cache_epoch || 0
|
|
141
|
+
return [base] if key.nil?
|
|
142
|
+
|
|
143
|
+
gens = @cache_generations || {}
|
|
144
|
+
own = gens[key] || 0
|
|
145
|
+
reads = key.is_a?(String) && key.start_with?('read:') ? (gens[:'read:*'] || 0) : 0
|
|
146
|
+
[base, own, reads]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [void] (call while holding cache_entries_mutex)
|
|
150
|
+
def bump_cache_epoch
|
|
151
|
+
@cache_epoch = (@cache_epoch || 0) + 1
|
|
152
|
+
# Every key's generation moved with the base: the per-key counts have
|
|
153
|
+
# nothing left to add.
|
|
154
|
+
@cache_generations = nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param key [Symbol, String] a cache key, or :'read:*' for every read
|
|
158
|
+
# @return [void] (call while holding cache_entries_mutex)
|
|
159
|
+
def bump_cache_generation(key)
|
|
160
|
+
@cache_generations ||= {}
|
|
161
|
+
@cache_generations[key] = (@cache_generations[key] || 0) + 1
|
|
162
|
+
return unless key.is_a?(String) && @cache_generations.count { |k, _| k.is_a?(String) } > MAX_READ_GENERATIONS
|
|
163
|
+
|
|
164
|
+
# Too many URIs to remember one by one: fold them into the count that
|
|
165
|
+
# invalidates every read. The shared count jumps past the largest
|
|
166
|
+
# per-URI count it absorbs, so no key's generation stands still or
|
|
167
|
+
# goes back — a read still in flight cannot be stored against a
|
|
168
|
+
# generation this fold already left behind.
|
|
169
|
+
absorbed = @cache_generations.select { |k, _| k.is_a?(String) }.values.max || 0
|
|
170
|
+
@cache_generations.delete_if { |k, _| k.is_a?(String) }
|
|
171
|
+
@cache_generations[:'read:*'] = (@cache_generations[:'read:*'] || 0) + absorbed + 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Record the freshness hint of one result.
|
|
175
|
+
# @param kind [Symbol, String] :tools, :prompts, :resources, :templates, :discover or "read:<uri>"
|
|
176
|
+
# @param result [Hash] the CacheableResult
|
|
177
|
+
# @param value [Object] what the cache holds for this kind (may be nil: hint only)
|
|
178
|
+
# @return [MCPClient::CachedResult]
|
|
179
|
+
# @param epoch [Integer, nil] the cache epoch captured before the request went out: a result
|
|
180
|
+
# whose entry was invalidated meanwhile is recorded as stale, not as fresh
|
|
181
|
+
def record_cache_hint(kind, result, value = nil, epoch: nil, received_at: nil)
|
|
182
|
+
now = received_at || response_received_at
|
|
183
|
+
entry = cache_entry_for(result, value, now: now)
|
|
184
|
+
cache_entries_mutex.synchronize do
|
|
185
|
+
if epoch && epoch != cache_generation(kind)
|
|
186
|
+
# Invalidated while in flight: whatever is installed now (the
|
|
187
|
+
# invalidation's placeholder, or a newer fetch) stays; this result
|
|
188
|
+
# is reported stale and never stored.
|
|
189
|
+
entry = MCPClient::CachedResult.stale(now: now, like: entry)
|
|
190
|
+
else
|
|
191
|
+
cache_entries[kind] = entry
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
remember_recorded_entry(kind, entry)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Build the entry for one result: an absent ttlMs counts as 0 on a
|
|
198
|
+
# 2026-07-28 server, and the entry remembers the authorization context
|
|
199
|
+
# of the request that produced it (transports that know it).
|
|
200
|
+
# @return [MCPClient::CachedResult]
|
|
201
|
+
# @param assume_zero [Boolean] treat an absent ttlMs as 0; not for a
|
|
202
|
+
# DiscoverResult, which is the session's negotiated state and stays in
|
|
203
|
+
# force until the next probe when it carries no hint
|
|
204
|
+
def cache_entry_for(result, value, now:, assume_zero: assume_zero_ttl?)
|
|
205
|
+
entry = MCPClient::CachedResult.from_result(result, value, now: now, assume_zero: assume_zero)
|
|
206
|
+
bind_authorization_context(entry)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @param entry [MCPClient::CachedResult]
|
|
210
|
+
# @return [MCPClient::CachedResult] the same entry, bound to the current request's context
|
|
211
|
+
def bind_authorization_context(entry)
|
|
212
|
+
if respond_to?(:request_authorization_context, true)
|
|
213
|
+
# An entry whose request nothing could record belongs to no context
|
|
214
|
+
# rather than to the anonymous one: the two are the same `nil`, and
|
|
215
|
+
# filing an authenticated result as anonymous is what hands it to the
|
|
216
|
+
# next caller who sends no credentials at all.
|
|
217
|
+
entry.authorization_context =
|
|
218
|
+
sent_authorization_known? ? request_authorization_context : MCPClient::CachedResult::UNKNOWN_CONTEXT
|
|
219
|
+
end
|
|
220
|
+
entry.params_fingerprint = request_params_fingerprint if respond_to?(:request_params_fingerprint, true)
|
|
221
|
+
entry
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Whether what the request behind an entry went out with is known at all.
|
|
225
|
+
# A transport that applies its own headers and nothing else always knows;
|
|
226
|
+
# {MCPClient::HttpTransportBase::CacheSupport} answers for a connection
|
|
227
|
+
# carrying host middleware.
|
|
228
|
+
# @return [Boolean]
|
|
229
|
+
def sent_authorization_known?
|
|
230
|
+
true
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @return [Boolean] whether an absent ttlMs means "immediately stale" (2026-07-28 servers)
|
|
234
|
+
def assume_zero_ttl?
|
|
235
|
+
respond_to?(:modern?) && modern?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Record the hint of an auto-paginated list from its pages (shortest TTL wins).
|
|
239
|
+
# @param kind [Symbol] the list kind
|
|
240
|
+
# @param page_results [Array<Hash>] the per-page results
|
|
241
|
+
# @param value [Object] what the cache holds (may be nil)
|
|
242
|
+
# @return [MCPClient::CachedResult, nil]
|
|
243
|
+
# @param received_ats [Array<Float>, nil] each page's monotonic receipt time (defaults to now)
|
|
244
|
+
# @param contexts [Array<String, nil>, nil] each page's request authorization context
|
|
245
|
+
# @param epoch [Integer, nil] the cache epoch when the first page was requested
|
|
246
|
+
def record_paginated_cache_hint(kind, page_results, value = nil, received_ats: nil, contexts: nil, params: nil,
|
|
247
|
+
epoch: nil)
|
|
248
|
+
now = monotonic_now
|
|
249
|
+
entries = page_results.each_with_index.map do |result, index|
|
|
250
|
+
# A bare array page (accepted for compatibility) carries no hint:
|
|
251
|
+
# on a modern server that means ttlMs 0.
|
|
252
|
+
MCPClient::CachedResult.from_result(result.is_a?(Hash) ? result : {}, nil,
|
|
253
|
+
now: (received_ats && received_ats[index]) || now,
|
|
254
|
+
assume_zero: assume_zero_ttl?)
|
|
255
|
+
end
|
|
256
|
+
return nil if entries.empty?
|
|
257
|
+
|
|
258
|
+
combined = bind_authorization_context(MCPClient::CachedResult.combine(entries, value, now: now))
|
|
259
|
+
# A list invalidated while it was being fetched is already stale and
|
|
260
|
+
# never replaces what is installed now (the invalidation's placeholder
|
|
261
|
+
# or a newer fetch); a private list whose pages were fetched under
|
|
262
|
+
# different credentials belongs to no single context, and a list whose
|
|
263
|
+
# pages were fetched under differing effective parameters (the host's
|
|
264
|
+
# request_meta changed between pages) matches no request's parameters,
|
|
265
|
+
# whatever its scope.
|
|
266
|
+
combined = mixed_pages_placeholder(combined, now, contexts: contexts, params: params)
|
|
267
|
+
cache_entries_mutex.synchronize do
|
|
268
|
+
if epoch && epoch != cache_generation(kind)
|
|
269
|
+
combined = MCPClient::CachedResult.stale(now: now, like: combined)
|
|
270
|
+
else
|
|
271
|
+
cache_entries[kind] = combined
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
remember_recorded_entry(kind, combined)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The entry to record for a combined list: the list itself, or — when
|
|
278
|
+
# its pages were fetched under differing credentials (a private list)
|
|
279
|
+
# or differing effective parameters (any list) — a stale placeholder
|
|
280
|
+
# that no context or parameters match.
|
|
281
|
+
# @param combined [MCPClient::CachedResult]
|
|
282
|
+
# @param now [Float]
|
|
283
|
+
# @param contexts [Array, nil] the pages' authorization contexts
|
|
284
|
+
# @param params [Array, nil] the pages' params fingerprints
|
|
285
|
+
# @return [MCPClient::CachedResult]
|
|
286
|
+
def mixed_pages_placeholder(combined, now, contexts:, params:)
|
|
287
|
+
mixed = combined.cache_scope == 'private' && contexts && contexts.uniq.size > 1
|
|
288
|
+
mixed_params = params && params.uniq.size > 1
|
|
289
|
+
return combined unless mixed || mixed_params
|
|
290
|
+
|
|
291
|
+
placeholder = MCPClient::CachedResult.stale(now: now, like: combined)
|
|
292
|
+
placeholder.authorization_context = MCPClient::CachedResult::MIXED_CONTEXT if mixed
|
|
293
|
+
placeholder.params_fingerprint = MCPClient::CachedResult::MIXED_PARAMS if mixed_params
|
|
294
|
+
placeholder
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Stamp the entry this thread's fetch recorded with a fresh identity and
|
|
298
|
+
# remember it, so the list the same fetch converts afterwards can be
|
|
299
|
+
# attached to its own entry and to no other (the thread keeps only the
|
|
300
|
+
# bare identity, never the entry or its list).
|
|
301
|
+
# @param kind [Symbol]
|
|
302
|
+
# @param entry [MCPClient::CachedResult]
|
|
303
|
+
# @return [MCPClient::CachedResult] the entry
|
|
304
|
+
def remember_recorded_entry(kind, entry)
|
|
305
|
+
entry.fetch_token = Object.new
|
|
306
|
+
# Only a list attaches its value afterwards and takes its identity
|
|
307
|
+
# back out again: remembering any other kind (a discovery, which is
|
|
308
|
+
# never attached) would leave a token on the thread for the life of
|
|
309
|
+
# the thread, one per transport a long-lived worker ever built.
|
|
310
|
+
return entry unless LIST_VALUE_KINDS.include?(kind)
|
|
311
|
+
|
|
312
|
+
(Thread.current[recorded_entries_key] ||= {})[kind] = entry.fetch_token
|
|
313
|
+
entry
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# @return [Symbol] the thread-local key of this server's recorded entries
|
|
317
|
+
def recorded_entries_key
|
|
318
|
+
:"mcp_client_recorded_entries_#{object_id}"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Remember, per thread, the entry a list of a kind was last served or
|
|
322
|
+
# attached from — its identity and the parameters it is bound to — so a
|
|
323
|
+
# cache built on top (the client's) can tie its slice to that very entry.
|
|
324
|
+
# @param kind [Symbol]
|
|
325
|
+
# @param entry [MCPClient::CachedResult, nil] the entry (nil: none)
|
|
326
|
+
# @return [void]
|
|
327
|
+
def note_served_entry(kind, entry)
|
|
328
|
+
(Thread.current[served_entries_key] ||= {})[kind] = entry && [entry.fetch_token, entry.params_fingerprint]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Note that this thread's last list of a kind carried no hint at all.
|
|
332
|
+
# @param kind [Symbol]
|
|
333
|
+
# @return [Symbol] LEGACY_ENTRY
|
|
334
|
+
def note_legacy_served(kind)
|
|
335
|
+
(Thread.current[served_entries_key] ||= {})[kind] = [LEGACY_ENTRY, nil]
|
|
336
|
+
LEGACY_ENTRY
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Take the note left for a kind: it is written for the one cache above
|
|
340
|
+
# this transport that tags its slice with it, so reading it consumes it.
|
|
341
|
+
# The slot itself goes once nothing is left in it, rather than staying on
|
|
342
|
+
# the thread for the life of a worker that lists through many transports.
|
|
343
|
+
# @param kind [Symbol]
|
|
344
|
+
# @return [Array(Object, String), nil] the identity of the entry this
|
|
345
|
+
# thread's last list of the kind came from and the parameters
|
|
346
|
+
# fingerprint it is bound to
|
|
347
|
+
def take_served_entry(kind)
|
|
348
|
+
notes = Thread.current[served_entries_key]
|
|
349
|
+
return nil if notes.nil?
|
|
350
|
+
|
|
351
|
+
note = notes.delete(kind)
|
|
352
|
+
Thread.current[served_entries_key] = nil if notes.empty?
|
|
353
|
+
note
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Drop every note this thread holds for this transport (its connection
|
|
357
|
+
# is going away, so nothing will tag a slice with them).
|
|
358
|
+
# @return [void]
|
|
359
|
+
def forget_served_entries
|
|
360
|
+
Thread.current[served_entries_key] = nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The thread-local slots a transport owns, each keyed by its own
|
|
364
|
+
# `object_id`: the notes of the entries it served and recorded, the
|
|
365
|
+
# receipt time, the credentials and effective parameters of the request
|
|
366
|
+
# this thread last sent through it, and its multi round-trip marker.
|
|
367
|
+
#
|
|
368
|
+
# The evaluation an open operation reserved for its own request is
|
|
369
|
+
# deliberately not among them: a reconnect tears the connection down
|
|
370
|
+
# (`ensure_connected` cleans up before it connects) in the middle of the
|
|
371
|
+
# very request a cache decision reserved it for, and that request must
|
|
372
|
+
# still carry it. The reservation belongs to the operation, which drops
|
|
373
|
+
# it when it ends ({MCPClient::RequestMetaScope}).
|
|
374
|
+
# @return [Array<Symbol>] the keys defined on this transport
|
|
375
|
+
def transport_thread_local_keys
|
|
376
|
+
%i[served_entries_key recorded_entries_key response_received_key
|
|
377
|
+
request_params_key round_trip_marker_key request_authorization_key
|
|
378
|
+
exchange_records_key called_tool_definition_key pinned_retry_definition_key]
|
|
379
|
+
.select { |name| respond_to?(name, true) }
|
|
380
|
+
.map { |name| send(name) }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Drop everything this transport left on the calling thread: its
|
|
384
|
+
# connection is going away, so none of it describes a request that will
|
|
385
|
+
# ever be made or a slice that will ever be tagged. A worker thread that
|
|
386
|
+
# creates and discards transports would otherwise accumulate one entry
|
|
387
|
+
# per slot per transport for its whole life.
|
|
388
|
+
# @return [void]
|
|
389
|
+
def forget_transport_thread_state
|
|
390
|
+
transport_thread_local_keys.each { |key| Thread.current[key] = nil }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Whether the entry a client-level slice came from is still fresh by its
|
|
394
|
+
# own hint — a lock-safe re-check (no probe, no host callable) for the
|
|
395
|
+
# moment a snapshot is handed out. No entry means nothing bounds it.
|
|
396
|
+
# @param kind [Symbol]
|
|
397
|
+
# @return [Boolean]
|
|
398
|
+
def cache_entry_fresh?(kind)
|
|
399
|
+
cache_entries_mutex.synchronize do
|
|
400
|
+
entry = cache_entries[kind]
|
|
401
|
+
entry.nil? || (!entry.value.nil? && entry.fresh?(now: monotonic_now))
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Whether the entry holding a kind bounds its own freshness: a server
|
|
406
|
+
# that sent a ttlMs (or a 2026-07-28 server whose absent ttlMs means 0)
|
|
407
|
+
# says how long its list may be kept, empty or not. Without a hint the
|
|
408
|
+
# client's own heuristic applies instead, and an empty list is asked for
|
|
409
|
+
# again rather than kept for the life of the connection.
|
|
410
|
+
# @param kind [Symbol]
|
|
411
|
+
# @return [Boolean]
|
|
412
|
+
def cache_entry_hinted?(kind)
|
|
413
|
+
cache_entries_mutex.synchronize do
|
|
414
|
+
entry = cache_entries[kind]
|
|
415
|
+
!entry.nil? && !entry.value.nil? && entry.hint?
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# @param kind [Symbol]
|
|
420
|
+
# @return [Object, nil] the identity of the entry currently holding the kind
|
|
421
|
+
def cache_entry_token(kind)
|
|
422
|
+
# A placeholder (an invalidation, a cleanup) identifies nothing.
|
|
423
|
+
cache_entries_mutex.synchronize do
|
|
424
|
+
entry = cache_entries[kind]
|
|
425
|
+
entry&.value.nil? ? nil : entry.fetch_token
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @return [Symbol] the thread-local key of this server's served entries
|
|
430
|
+
def served_entries_key
|
|
431
|
+
:"mcp_client_served_entries_#{object_id}"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Called by the paginated list helper with the raw page results.
|
|
435
|
+
# @param method [String] the list method
|
|
436
|
+
# @param page_results [Array<Hash>]
|
|
437
|
+
# @return [void]
|
|
438
|
+
# @param received_ats [Array<Float>, nil] each page's monotonic receipt time
|
|
439
|
+
# @param contexts [Array<String, nil>, nil] each page's request authorization context
|
|
440
|
+
# @param epoch [Integer, nil] the cache epoch when the first page was requested
|
|
441
|
+
def record_list_cache_hint(method, page_results, received_ats = nil, contexts: nil, params: nil, epoch: nil)
|
|
442
|
+
kind = LIST_METHOD_KINDS[method]
|
|
443
|
+
return unless kind
|
|
444
|
+
|
|
445
|
+
record_paginated_cache_hint(kind, page_results, received_ats: received_ats, contexts: contexts, params: params,
|
|
446
|
+
epoch: epoch)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Whether the cached response for a kind may still be served. No entry
|
|
450
|
+
# (nothing cached yet) or no hint (older server) means the client's own
|
|
451
|
+
# heuristic applies: cache until a change notification.
|
|
452
|
+
# @param kind [Symbol, String]
|
|
453
|
+
# @return [Boolean]
|
|
454
|
+
def cache_fresh?(kind)
|
|
455
|
+
entry = private_entry_for_current_context(kind)
|
|
456
|
+
return true if entry.nil?
|
|
457
|
+
return false if LIST_VALUE_KINDS.include?(kind) && entry.value.nil?
|
|
458
|
+
|
|
459
|
+
entry.fresh?(now: monotonic_now)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# The list a transport with no cache of its own may serve for a kind:
|
|
463
|
+
# only one the server itself bounded ("If ttlMs is positive, the client
|
|
464
|
+
# SHOULD consider the result fresh for that many milliseconds"). A list
|
|
465
|
+
# without a hint is left to the client's own cache, which asks again for
|
|
466
|
+
# an empty one instead of keeping it for the life of the connection.
|
|
467
|
+
# @param kind [Symbol]
|
|
468
|
+
# @return [Object, nil]
|
|
469
|
+
def hinted_list_value(kind)
|
|
470
|
+
cache_entry_hinted?(kind) ? fresh_list_value(kind) : nil
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# The list a transport may serve for a kind without fetching: the value
|
|
474
|
+
# of a fresh entry in the current authorization context. With no entry
|
|
475
|
+
# at all (nothing recorded yet) the transport's own copy, given by the
|
|
476
|
+
# block, stands in; a hint without a value never lets that copy through.
|
|
477
|
+
# @param kind [Symbol]
|
|
478
|
+
# @yield the transport's own copy of the list
|
|
479
|
+
# @return [Object, nil]
|
|
480
|
+
def fresh_list_value(kind)
|
|
481
|
+
entry = private_entry_for_current_context(kind)
|
|
482
|
+
if entry.nil?
|
|
483
|
+
note_legacy_served(kind)
|
|
484
|
+
# The transport's own copy stands in for a list an older server put
|
|
485
|
+
# no hint on -- unless it is empty: an empty unhinted list is asked
|
|
486
|
+
# for again, the way the client's own cache treats it, rather than
|
|
487
|
+
# kept for the life of the connection.
|
|
488
|
+
copy = block_given? ? yield : nil
|
|
489
|
+
return empty_list_copy?(copy) ? nil : copy
|
|
490
|
+
end
|
|
491
|
+
# An invalidation (a list_changed notification, a cleanup) that lands
|
|
492
|
+
# after the lookup — the authorization probe makes that a wide window —
|
|
493
|
+
# replaces the slot but leaves this reference intact: the copy is made
|
|
494
|
+
# under the lock, and only while the entry is still the one the map
|
|
495
|
+
# holds and still fresh.
|
|
496
|
+
copy = cache_entries_mutex.synchronize do
|
|
497
|
+
next nil unless cache_entries[kind].equal?(entry) && entry.value && entry.fresh?(now: monotonic_now)
|
|
498
|
+
# An empty list an older server put no hint on (an entry kept until
|
|
499
|
+
# a change notification) is asked for again, the way the client's
|
|
500
|
+
# own cache treats it, rather than kept for the life of the connection.
|
|
501
|
+
next nil if entry.ttl_ms.nil? && empty_list_copy?(entry.value)
|
|
502
|
+
|
|
503
|
+
MCPClient::DeepCopy.copy(entry.value)
|
|
504
|
+
end
|
|
505
|
+
return nil unless copy
|
|
506
|
+
|
|
507
|
+
release_serving_request_meta
|
|
508
|
+
note_served_entry(kind, entry)
|
|
509
|
+
copy
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# @param copy [Object, nil] a transport's own copy of a list
|
|
513
|
+
# @return [Boolean] whether it lists nothing (an Array, or a page Hash
|
|
514
|
+
# whose list member is empty)
|
|
515
|
+
def empty_list_copy?(copy)
|
|
516
|
+
case copy
|
|
517
|
+
when Array then copy.empty?
|
|
518
|
+
when Hash then copy.values.any?(Array) && copy.values.grep(Array).all?(&:empty?)
|
|
519
|
+
else false
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# The list recorded for a kind whatever its freshness: the candidate for
|
|
524
|
+
# serving stale when a re-fetch fails ({#stale_fallback_for} decides).
|
|
525
|
+
# @param kind [Symbol]
|
|
526
|
+
# @return [Object, nil]
|
|
527
|
+
def stale_list_value(kind)
|
|
528
|
+
cache_entries_mutex.synchronize { cache_entries[kind]&.value }
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# The entry whose (possibly stale) list may be served when a re-fetch
|
|
532
|
+
# fails; the entry itself, so the fallback is judged by the entry that
|
|
533
|
+
# supplied the value and not by whatever entry is installed by then.
|
|
534
|
+
# @param kind [Symbol]
|
|
535
|
+
# @return [MCPClient::CachedResult, nil]
|
|
536
|
+
def stale_list_entry(kind)
|
|
537
|
+
cache_entries_mutex.synchronize { cache_entries[kind] }
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# The entry for a kind, after making sure a privately scoped one still
|
|
541
|
+
# belongs to the current authorization context (transports that know
|
|
542
|
+
# their context re-check it here; a changed context drops the entry).
|
|
543
|
+
# @param kind [Symbol, String]
|
|
544
|
+
# @return [MCPClient::CachedResult, nil]
|
|
545
|
+
def private_entry_for_current_context(kind)
|
|
546
|
+
entry = cache_entries_mutex.synchronize { cache_entries[kind] }
|
|
547
|
+
return entry if entry_in_current_context?(entry, kind: kind)
|
|
548
|
+
|
|
549
|
+
# Another context's private entry reads as known-and-stale, never as
|
|
550
|
+
# "nothing cached" (which would count as fresh) and never as a value.
|
|
551
|
+
MCPClient::CachedResult.stale(now: monotonic_now)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# @param entry [MCPClient::CachedResult, nil]
|
|
555
|
+
# @return [Boolean] whether the entry may be served in the current authorization context
|
|
556
|
+
# @param context [String, nil, :current, :unknown] the authorization context to check against
|
|
557
|
+
# (:current asks the transport which credentials it would send now for the operation of
|
|
558
|
+
# `kind`; :unknown means the credentials are not known, so no private entry matches)
|
|
559
|
+
# @param kind [Symbol, String, nil] the cache kind, so the transport models the request of
|
|
560
|
+
# that very operation (middleware may pick credentials by method or body)
|
|
561
|
+
def entry_in_current_context?(entry, context: :current, kind: nil)
|
|
562
|
+
return false if entry && !entry_for_current_params?(entry, context)
|
|
563
|
+
|
|
564
|
+
# The metadata this lookup evaluated stays held: an entry that belongs
|
|
565
|
+
# to the context may still be too stale to serve, and the request that
|
|
566
|
+
# then goes out carries the very evaluation the decision was made on.
|
|
567
|
+
# {#release_serving_request_meta} drops it once a value really is
|
|
568
|
+
# served instead.
|
|
569
|
+
entry_matches_authorization?(entry, context, kind)
|
|
570
|
+
rescue StandardError
|
|
571
|
+
# The lookup aborted — the authorization probe raised, an OAuth
|
|
572
|
+
# refresh failed — so it builds no request at all. Whatever evaluation
|
|
573
|
+
# of the host's request_meta it was holding for that request would
|
|
574
|
+
# otherwise sit on this thread and be sent, much later, by an
|
|
575
|
+
# unrelated request: the next request reads the host afresh instead.
|
|
576
|
+
release_serving_request_meta
|
|
577
|
+
raise
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# A cached value was served, so the lookup that led here leads to no
|
|
581
|
+
# request of its own: the metadata held for that request is dropped
|
|
582
|
+
# rather than sent, some time later, by another one.
|
|
583
|
+
# @return [void]
|
|
584
|
+
def release_serving_request_meta
|
|
585
|
+
release_held_request_meta if respond_to?(:release_held_request_meta, true)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# @param entry [MCPClient::CachedResult, nil]
|
|
589
|
+
# @param context [String, nil, :current, :unknown]
|
|
590
|
+
# @param kind [Symbol, String, nil]
|
|
591
|
+
# @return [Boolean] whether a privately scoped entry belongs to the context being served
|
|
592
|
+
def entry_matches_authorization?(entry, context, kind)
|
|
593
|
+
return true unless entry&.cache_scope == 'private' && respond_to?(:current_authorization_context, true)
|
|
594
|
+
# An entry that belongs to no context matches none: a private list
|
|
595
|
+
# whose pages were fetched under different credentials, or one whose
|
|
596
|
+
# request nothing could record. Both are sentinels rather than a
|
|
597
|
+
# header, so neither can be equal to a context being served.
|
|
598
|
+
return false unless entry.authorization_context.nil? || entry.authorization_context.is_a?(String)
|
|
599
|
+
|
|
600
|
+
context = current_authorization_context(kind) if context == :current
|
|
601
|
+
entry.authorization_context == context
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Whether an entry was produced by a request carrying the effective
|
|
605
|
+
# parameters (host `_meta`) the request being served would carry: the
|
|
606
|
+
# next request's for a :current lookup, the failed attempt's own when a
|
|
607
|
+
# stale fallback is judged. A result is never served across them,
|
|
608
|
+
# whatever its scope.
|
|
609
|
+
# @param entry [MCPClient::CachedResult]
|
|
610
|
+
# @param context [String, nil, :current, :unknown]
|
|
611
|
+
# @return [Boolean]
|
|
612
|
+
def entry_for_current_params?(entry, context)
|
|
613
|
+
return false if entry.params_fingerprint.equal?(MCPClient::CachedResult::MIXED_PARAMS)
|
|
614
|
+
return true unless entry.params_fingerprint && respond_to?(:current_params_fingerprint, true)
|
|
615
|
+
|
|
616
|
+
expected = context == :current ? current_params_fingerprint : request_params_fingerprint
|
|
617
|
+
# A failed attempt that never built its request noted no parameters:
|
|
618
|
+
# it matches no entry, whatever the previous request on this thread
|
|
619
|
+
# carried.
|
|
620
|
+
# Reading the next request's parameters evaluates a host request_meta
|
|
621
|
+
# callable, and the transport holds that evaluation for the request
|
|
622
|
+
# this lookup leads to (or for the probe that models it); the caller
|
|
623
|
+
# drops it once the entry is served instead.
|
|
624
|
+
expected.is_a?(String) && entry.params_fingerprint == expected
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Attach the list a request produced to the very entry that request
|
|
628
|
+
# recorded (the one this thread's fetch created, or the one passed in),
|
|
629
|
+
# so a value can never land on another request's TTL, scope or
|
|
630
|
+
# authorization context: when a later fetch has replaced the entry the
|
|
631
|
+
# value is dropped and the later fetch wins.
|
|
632
|
+
# @param kind [Symbol]
|
|
633
|
+
# @param value [Object] the list objects
|
|
634
|
+
# @param entry [MCPClient::CachedResult, nil] the entry the fetch recorded
|
|
635
|
+
# (defaults to the one recorded on this thread)
|
|
636
|
+
# @return [Boolean] whether the value was attached
|
|
637
|
+
def attach_list_value(kind, value, entry: nil)
|
|
638
|
+
recorded = Thread.current[recorded_entries_key]
|
|
639
|
+
token = entry ? entry.fetch_token : recorded&.delete(kind)
|
|
640
|
+
# Nothing of this transport's is outstanding on this thread any more.
|
|
641
|
+
Thread.current[recorded_entries_key] = nil if recorded && recorded.empty?
|
|
642
|
+
note_served_entry(kind, nil)
|
|
643
|
+
# Nothing recorded for this fetch: a legacy list without a hint, which
|
|
644
|
+
# the transport keeps until a list_changed notification. A recorded
|
|
645
|
+
# hint whose entry is gone or replaced is a rejection (false).
|
|
646
|
+
return note_legacy_served(kind) unless token
|
|
647
|
+
|
|
648
|
+
context = respond_to?(:request_authorization_context, true) ? request_authorization_context : nil
|
|
649
|
+
cache_entries_mutex.synchronize do
|
|
650
|
+
entry = cache_entries[kind]
|
|
651
|
+
return false unless entry && entry.fetch_token.equal?(token)
|
|
652
|
+
return false if entry.cache_scope == 'private' && entry.authorization_context != context
|
|
653
|
+
|
|
654
|
+
# The cache keeps its own copy: what the fetch returns to its caller
|
|
655
|
+
# may be changed freely.
|
|
656
|
+
entry.value = MCPClient::DeepCopy.copy(value)
|
|
657
|
+
note_served_entry(kind, entry)
|
|
658
|
+
true
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# The cached list for a kind, when its entry is fresh and belongs to
|
|
663
|
+
# the current authorization context.
|
|
664
|
+
# @param kind [Symbol]
|
|
665
|
+
# @return [Object, nil]
|
|
666
|
+
def cached_list_value(kind)
|
|
667
|
+
entry = private_entry_for_current_context(kind)
|
|
668
|
+
return nil unless entry&.value && entry.fresh?(now: monotonic_now)
|
|
669
|
+
|
|
670
|
+
release_serving_request_meta
|
|
671
|
+
entry.value
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# The stale copy that may be served when a re-fetch fails: the value of
|
|
675
|
+
# the entry captured before the re-fetch, and only when that very entry
|
|
676
|
+
# belongs to the authorization context (checked against the credentials
|
|
677
|
+
# the failed request actually used, when the caller knows them) — an
|
|
678
|
+
# entry installed meanwhile by another request never vouches for it.
|
|
679
|
+
# @param kind [Symbol]
|
|
680
|
+
# @param entry [MCPClient::CachedResult, nil] the entry captured before the re-fetch
|
|
681
|
+
# @param context [String, nil, :current] the authorization context to check against
|
|
682
|
+
# @return [Object, nil]
|
|
683
|
+
def stale_fallback_for(kind, entry, context: :current)
|
|
684
|
+
return nil unless entry.is_a?(MCPClient::CachedResult) && entry.value
|
|
685
|
+
# An entry a cleanup or an invalidation replaced while the re-fetch
|
|
686
|
+
# was in flight is forgotten: only the entry still in the slot serves.
|
|
687
|
+
return nil unless cache_entries_mutex.synchronize { cache_entries[kind].equal?(entry) }
|
|
688
|
+
|
|
689
|
+
note_served_entry(kind, entry)
|
|
690
|
+
return nil unless entry_in_current_context?(entry, context: context, kind: kind)
|
|
691
|
+
|
|
692
|
+
# Judging the context runs the probe, and an invalidation may land
|
|
693
|
+
# while it does: the copy is taken under the lock, and only while the
|
|
694
|
+
# entry is still the one the map holds.
|
|
695
|
+
cache_entries_mutex.synchronize do
|
|
696
|
+
next nil unless cache_entries[kind].equal?(entry) && entry.value
|
|
697
|
+
|
|
698
|
+
MCPClient::DeepCopy.copy(entry.value)
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# The freshness hint recorded for an operation.
|
|
703
|
+
# @param kind [Symbol] :discover, :tools, :prompts, :resources, :templates or :read
|
|
704
|
+
# @param key [String, nil] the resource URI for :read
|
|
705
|
+
# @return [Hash, nil] ttl_ms, cache_scope, received_at, fresh — nil when nothing was recorded
|
|
706
|
+
def cache_info(kind, key = nil)
|
|
707
|
+
entry = cache_entries_mutex.synchronize { cache_entries[kind == :read ? read_cache_key(key) : kind] }
|
|
708
|
+
entry&.to_info(now: monotonic_now)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Mark a kind stale: a change notification invalidates a still-fresh
|
|
712
|
+
# cache, so the kind must read as stale (not as "nothing known", which
|
|
713
|
+
# would let a concurrently snapshotted list be served) until the next
|
|
714
|
+
# fetch records a new hint.
|
|
715
|
+
# @param kind [Symbol, String]
|
|
716
|
+
# @return [void]
|
|
717
|
+
def invalidate_cache(kind)
|
|
718
|
+
now = monotonic_now
|
|
719
|
+
cache_entries_mutex.synchronize do
|
|
720
|
+
cache_entries[kind] = MCPClient::CachedResult.stale(now: now, like: cache_entries[kind])
|
|
721
|
+
bump_cache_generation(kind)
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# The JSON-RPC code a server answers a cursor it no longer accepts with
|
|
726
|
+
# (MCP pagination: an invalid cursor SHOULD be an -32602 Invalid params).
|
|
727
|
+
# @param error [Exception] the failure a page request raised
|
|
728
|
+
# @return [Boolean]
|
|
729
|
+
def invalid_cursor_error?(error)
|
|
730
|
+
error.is_a?(MCPClient::Errors::ServerError) && error.code == MCPClient::Errors::Codes::INVALID_PARAMS
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Run one page request of a paginated list, dropping the pages cached for
|
|
734
|
+
# that list when the server rejects the cursor it carried. A cursor names
|
|
735
|
+
# a position in one sequence of pages: once the server has forgotten it,
|
|
736
|
+
# the first page cached from that sequence is gone with it, and serving
|
|
737
|
+
# that page again would hand the caller the same dead cursor to follow.
|
|
738
|
+
# A rejection of the *first* page's request carries no cursor and says
|
|
739
|
+
# nothing about the cache, so it leaves it alone.
|
|
740
|
+
# @param kind [Symbol, nil] the list kind
|
|
741
|
+
# @param cursor [String, nil] the cursor this page request carries
|
|
742
|
+
# @yield sends the page request
|
|
743
|
+
# @return [Object] the block's value
|
|
744
|
+
def fetching_list_page(kind, cursor)
|
|
745
|
+
yield
|
|
746
|
+
rescue MCPClient::Errors::ServerError => e
|
|
747
|
+
raise unless kind && cursor && invalid_cursor_error?(e)
|
|
748
|
+
|
|
749
|
+
discard_paginated_list(kind)
|
|
750
|
+
raise
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Forget everything cached for a paginated list: the entry that bounds it
|
|
754
|
+
# and the transport's own copy, so the next access really re-fetches from
|
|
755
|
+
# the first page.
|
|
756
|
+
# @param kind [Symbol] the list kind
|
|
757
|
+
# @return [void]
|
|
758
|
+
def discard_paginated_list(kind)
|
|
759
|
+
invalidate_cache(kind)
|
|
760
|
+
invalidate_list_cache(kind) if respond_to?(:invalidate_list_cache, true)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Forget every cached result and hint (the connection, and with it the
|
|
764
|
+
# authorization context, is gone).
|
|
765
|
+
# @return [void]
|
|
766
|
+
def clear_result_cache
|
|
767
|
+
now = monotonic_now
|
|
768
|
+
cache_entries_mutex.synchronize do
|
|
769
|
+
# Lists stay known-and-stale (a client-level cache built from the old
|
|
770
|
+
# connection must not read an empty entry as "fresh"); reads are
|
|
771
|
+
# simply forgotten, they are only ever served with a value.
|
|
772
|
+
cache_entries.delete_if { |key, _| key.is_a?(String) }
|
|
773
|
+
PLACEHOLDER_KINDS.each { |kind| cache_entries[kind] ||= nil }
|
|
774
|
+
cache_entries.each_key do |key|
|
|
775
|
+
# Whoever still holds the replaced object (a re-fetch in flight)
|
|
776
|
+
# must not serve its list either.
|
|
777
|
+
cache_entries[key]&.value = nil
|
|
778
|
+
cache_entries[key] = MCPClient::CachedResult.stale(now: now, like: cache_entries[key])
|
|
779
|
+
end
|
|
780
|
+
bump_cache_epoch
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# The Authorization value in a header collection, whatever the key's
|
|
785
|
+
# spelling: HTTP field names are case-insensitive and hosts configure
|
|
786
|
+
# them as strings or symbols (`Authorization:`, 'AUTHORIZATION').
|
|
787
|
+
# @param headers [Hash, #[], nil]
|
|
788
|
+
# @return [String, nil]
|
|
789
|
+
def authorization_header_value(headers)
|
|
790
|
+
MCPClient::ResultCaching.authorization_header_value(headers)
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# @see #authorization_header_value (usable from middleware classes too)
|
|
794
|
+
# @param headers [Hash, #[], nil]
|
|
795
|
+
# @return [String, nil]
|
|
796
|
+
def self.authorization_header_value(headers)
|
|
797
|
+
return nil if headers.nil?
|
|
798
|
+
# Faraday's own table already holds one entry per field name.
|
|
799
|
+
return headers['Authorization'] if headers.is_a?(Faraday::Utils::Headers)
|
|
800
|
+
return headers['Authorization'] || headers['authorization'] unless headers.respond_to?(:each_pair)
|
|
801
|
+
|
|
802
|
+
# A plain Hash can hold several spellings at once (a configured
|
|
803
|
+
# `authorization:` and an OAuth provider's canonical `Authorization`).
|
|
804
|
+
# Faraday copies them into a case-insensitive table, so the request
|
|
805
|
+
# carries what the last of them writes — and so must the fingerprint.
|
|
806
|
+
value = nil
|
|
807
|
+
headers.each_pair { |key, header| value = header if key.to_s.casecmp?('authorization') }
|
|
808
|
+
value
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# The header table a Faraday request built from these headers carries:
|
|
812
|
+
# field names are case-insensitive there, so several spellings of one
|
|
813
|
+
# header collapse into a single entry and a later write of any spelling
|
|
814
|
+
# (an OAuth provider's canonical `Authorization`) replaces it rather
|
|
815
|
+
# than leaving the older one behind.
|
|
816
|
+
#
|
|
817
|
+
# The table is always a detached copy: its caller hands it to the OAuth
|
|
818
|
+
# provider, which writes the Authorization it would apply into it, and
|
|
819
|
+
# that must never reach the headers the transport builds its requests
|
|
820
|
+
# from (a probed token would outlive the credentials it came from).
|
|
821
|
+
# @param headers [Hash, #each_pair, nil]
|
|
822
|
+
# @return [Faraday::Utils::Headers]
|
|
823
|
+
def self.faraday_headers(headers)
|
|
824
|
+
# Faraday's own table dups its case-insensitive name index with it.
|
|
825
|
+
return headers.dup if headers.is_a?(Faraday::Utils::Headers)
|
|
826
|
+
return Faraday::Utils::Headers.new unless headers.respond_to?(:each_pair)
|
|
827
|
+
|
|
828
|
+
Faraday::Utils::Headers.new(headers.to_h)
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# @see .faraday_headers
|
|
832
|
+
# @param headers [Hash, #each_pair, nil]
|
|
833
|
+
# @return [Faraday::Utils::Headers]
|
|
834
|
+
def faraday_headers(headers)
|
|
835
|
+
MCPClient::ResultCaching.faraday_headers(headers)
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# A stable, non-reversible identifier of an Authorization header, so a
|
|
839
|
+
# cache entry can be bound to the credentials that produced it without
|
|
840
|
+
# keeping the credentials themselves around.
|
|
841
|
+
# @param header [String, nil]
|
|
842
|
+
# @return [String, nil]
|
|
843
|
+
def authorization_fingerprint(header)
|
|
844
|
+
return nil if header.nil?
|
|
845
|
+
|
|
846
|
+
Digest::SHA256.hexdigest(header.to_s)
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Forget cached resources/read results: one URI, or all of them.
|
|
850
|
+
# @param uri [String, nil]
|
|
851
|
+
# @return [void]
|
|
852
|
+
def invalidate_read_cache(uri = nil)
|
|
853
|
+
cache_entries_mutex.synchronize do
|
|
854
|
+
if uri
|
|
855
|
+
cache_entries.delete(read_cache_key(uri))
|
|
856
|
+
bump_cache_generation(read_cache_key(uri))
|
|
857
|
+
else
|
|
858
|
+
cache_entries.delete_if { |key, _| key.is_a?(String) && key.start_with?('read:') }
|
|
859
|
+
bump_cache_generation(:'read:*')
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# @param uri [String]
|
|
865
|
+
# @return [String]
|
|
866
|
+
def read_cache_key(uri)
|
|
867
|
+
"read:#{uri}"
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Serve a cached resources/read while fresh; otherwise fetch, and cache
|
|
871
|
+
# the contents unless they came from a multi round-trip retry ("results
|
|
872
|
+
# produced by retrying a request through the multi round-trip requests
|
|
873
|
+
# mechanism MUST NOT be cached").
|
|
874
|
+
# @param uri [String] the resource URI
|
|
875
|
+
# @yieldparam uri [String] the URI to put on the wire: the snapshot the
|
|
876
|
+
# entry is keyed by, not the caller's string as it may read by then
|
|
877
|
+
# @yieldreturn [Hash] the raw resources/read result
|
|
878
|
+
# @return [Array<MCPClient::ResourceContent>]
|
|
879
|
+
def read_resource_with_cache(uri)
|
|
880
|
+
# One immutable URI names the request, its key and its entry: a host
|
|
881
|
+
# that rewrites the string it passed while the read is under way (a
|
|
882
|
+
# concurrent caller, a callback) must not have B's contents filed
|
|
883
|
+
# under A ("a cached response MUST NOT be served across different
|
|
884
|
+
# request parameters").
|
|
885
|
+
uri = -uri.to_s
|
|
886
|
+
key = read_cache_key(uri)
|
|
887
|
+
cached = private_entry_for_current_context(key)
|
|
888
|
+
# An invalidation (a resources/updated notification, a cleanup) that
|
|
889
|
+
# lands after the lookup takes the entry out of the map but leaves
|
|
890
|
+
# this reference intact: the copy is made under the lock, and only
|
|
891
|
+
# while the entry is still the one the map holds.
|
|
892
|
+
served = cached && cache_entries_mutex.synchronize do
|
|
893
|
+
next nil unless cache_entries[key].equal?(cached) && cached.value && cached.fresh?(now: monotonic_now)
|
|
894
|
+
|
|
895
|
+
cached.value.map(&:dup)
|
|
896
|
+
end
|
|
897
|
+
if served
|
|
898
|
+
release_serving_request_meta
|
|
899
|
+
return served
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
epoch = cache_epoch(key)
|
|
903
|
+
started = monotonic_now
|
|
904
|
+
result = yield(uri)
|
|
905
|
+
# The TTL runs from receipt — before the response's notifications were
|
|
906
|
+
# dispatched — not from the end of the conversion below.
|
|
907
|
+
received_at = response_received_at(since: started)
|
|
908
|
+
unless result.is_a?(Hash)
|
|
909
|
+
raise MCPClient::Errors::TransportError,
|
|
910
|
+
"Invalid resources/read response: expected an object, got #{result.class}"
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# Projecting `contents` out of an unfinished answer would present it as
|
|
914
|
+
# an empty successful read -- and cache it. The guard runs before both.
|
|
915
|
+
require_complete_result!(result, 'resources/read')
|
|
916
|
+
contents = (result['contents'] || []).map { |content| MCPClient::ResourceContent.from_json(content) }
|
|
917
|
+
entry = cache_entry_for(result, contents, now: received_at)
|
|
918
|
+
# A read is cached only on an explicit, positive ttlMs: "if ttlMs is
|
|
919
|
+
# absent, clients SHOULD assume 0" — and reads were never cached
|
|
920
|
+
# before this revision, so a legacy server keeps that behaviour; a
|
|
921
|
+
# result that is stale on arrival would only take up memory. A result
|
|
922
|
+
# reached through a multi round-trip retry MUST NOT be cached either,
|
|
923
|
+
# nor one whose entry was invalidated while the request was in flight.
|
|
924
|
+
store_read_entry(key, entry, replacing: cached, epoch: epoch, now: received_at)
|
|
925
|
+
# The caller gets its own copies; the cached ones stay untouched.
|
|
926
|
+
contents.map(&:dup)
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Store a read's entry, or drop the slot it replaces. An uncacheable
|
|
930
|
+
# result is not stored, and the slot is dropped only when it still holds
|
|
931
|
+
# the entry the read set out to replace (its own context's, seen when it
|
|
932
|
+
# started): another context's private entry, or one a later fetch
|
|
933
|
+
# installed meanwhile, stays.
|
|
934
|
+
# @param key [String] the read cache key
|
|
935
|
+
# @param entry [MCPClient::CachedResult] the read's entry
|
|
936
|
+
# @param replacing [MCPClient::CachedResult, nil] the entry seen when the read started
|
|
937
|
+
# @param epoch [Integer] the cache epoch when the read started
|
|
938
|
+
# @param now [Float] the receipt time
|
|
939
|
+
# @return [void]
|
|
940
|
+
def store_read_entry(key, entry, replacing:, epoch:, now:)
|
|
941
|
+
cache_entries_mutex.synchronize do
|
|
942
|
+
if last_result_from_round_trip? || !entry.hint? || !entry.fresh?(now: now)
|
|
943
|
+
cache_entries.delete(key) if replacing && cache_entries[key].equal?(replacing)
|
|
944
|
+
elsif epoch == cache_generation(key)
|
|
945
|
+
prune_read_entries(now: now)
|
|
946
|
+
cache_entries[key] = entry
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Drop expired reads and, past {MAX_CACHED_READS}, the oldest ones, so
|
|
952
|
+
# a long-lived connection does not accumulate every URI ever read.
|
|
953
|
+
# (call while holding cache_entries_mutex)
|
|
954
|
+
# @param now [Float] monotonic time
|
|
955
|
+
# @return [void]
|
|
956
|
+
def prune_read_entries(now:)
|
|
957
|
+
reads = cache_entries.select { |k, _| k.is_a?(String) && k.start_with?('read:') }
|
|
958
|
+
reads.each { |k, entry| cache_entries.delete(k) unless entry.fresh?(now: now) }
|
|
959
|
+
reads = cache_entries.select { |k, _| k.is_a?(String) && k.start_with?('read:') }
|
|
960
|
+
while reads.size >= MAX_CACHED_READS
|
|
961
|
+
oldest = reads.min_by { |_, entry| entry.received_at }.first
|
|
962
|
+
cache_entries.delete(oldest)
|
|
963
|
+
reads.delete(oldest)
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# A host layered above the transport (MCPClient::Client) keeps caches of
|
|
968
|
+
# its own, and they must be gone before a subscription listener runs —
|
|
969
|
+
# the listener is delivered right after this returns, while the host's
|
|
970
|
+
# own notification callback runs last, after the delivery, so that host
|
|
971
|
+
# code cannot hold the delivery up.
|
|
972
|
+
# @yieldparam method [String] the notification method
|
|
973
|
+
# @yieldparam params [Hash, nil] the notification params
|
|
974
|
+
# @return [void]
|
|
975
|
+
def on_cache_invalidation(&block)
|
|
976
|
+
@cache_invalidation_callback = block
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# Keep caches in step with the server's change notifications: a list
|
|
980
|
+
# change drops that list (and, for resources, every cached read), a
|
|
981
|
+
# resource update drops that resource's read.
|
|
982
|
+
# @param method [String] a notification method
|
|
983
|
+
# @param params [Hash, nil] notification params
|
|
984
|
+
# @return [void]
|
|
985
|
+
def invalidate_cache_for_notification(method, params = nil)
|
|
986
|
+
kinds = LIST_CHANGE_NOTIFICATIONS[method]
|
|
987
|
+
if kinds
|
|
988
|
+
kinds.each do |kind|
|
|
989
|
+
invalidate_cache(kind)
|
|
990
|
+
invalidate_list_cache(kind) if respond_to?(:invalidate_list_cache, true)
|
|
991
|
+
invalidate_read_cache if kind == :resources
|
|
992
|
+
end
|
|
993
|
+
elsif method == 'notifications/resources/updated'
|
|
994
|
+
uri = params.is_a?(Hash) ? params['uri'] : nil
|
|
995
|
+
invalidate_read_cache(uri) if uri.is_a?(String)
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
end
|
|
999
|
+
end
|