ruby-mcp-client 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,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