Module: MCPClient::ResultCaching
- Included in:
- JsonRpcCommon
- Defined in:
- lib/mcp_client/result_caching.rb
Overview
Freshness bookkeeping for cacheable results (MCP 2026-07-28 server/utilities/caching), shared by every transport: hints recorded per operation (:discover, :tools, :prompts, :resources, :templates and per-URI reads), freshness checks, invalidation on change notifications, and the rule that multi round-trip retry results are never cached.
Constant Summary collapse
- LIST_CHANGE_NOTIFICATIONS =
Operation kinds with a list cache and the notification that invalidates them.
{ 'notifications/tools/list_changed' => i[tools], 'notifications/prompts/list_changed' => i[prompts], # resources/list_changed also covers resources/templates/list. 'notifications/resources/list_changed' => i[resources templates] }.freeze
- CACHE_INIT_LOCK =
Guards the lazy creation of the per-transport cache structures, which request threads and notification threads may touch first.
Mutex.new
- LIST_VALUE_KINDS =
Kinds whose cached list lives in the entry itself: a hint recorded for one of them without its list (the list is still being converted, or its conversion failed) is not a cache that may be served.
i[tools prompts resources templates].freeze
- PLACEHOLDER_KINDS =
Every list kind that gets a stale placeholder when the cache is cleared, recorded or not, so a copy stored while the clear happened is never served as an unhinted (legacy) list.
i[tools prompts resources templates].freeze
- LEGACY_ENTRY =
The served-entry identity of a list that carried no cache hint at all (a legacy server): nothing was recorded, and nothing was rejected.
:legacy- MAX_CACHED_READS =
How many resources/read results are kept at once: iterating many resources must not grow memory with every URI ever read.
64- MAX_READ_GENERATIONS =
How many per-URI invalidation generations are kept: a server varying the URI of notifications/resources/updated cannot grow the map without bound — past this, every read counts as invalidated at once instead.
256- LIST_METHOD_KINDS =
Paginated list methods and their cache kind.
{ 'tools/list' => :tools, 'prompts/list' => :prompts, 'resources/list' => :resources, 'resources/templates/list' => :templates }.freeze
Class Method Summary collapse
- .authorization_header_value(headers) ⇒ String?
-
.faraday_headers(headers) ⇒ Faraday::Utils::Headers
The header table a Faraday request built from these headers carries: field names are case-insensitive there, so several spellings of one header collapse into a single entry and a later write of any spelling (an OAuth provider's canonical
Authorization) replaces it rather than leaving the older one behind.
Instance Method Summary collapse
-
#assume_zero_ttl? ⇒ Boolean
Whether an absent ttlMs means "immediately stale" (2026-07-28 servers).
-
#attach_list_value(kind, value, entry: nil) ⇒ Boolean
Attach the list a request produced to the very entry that request recorded (the one this thread's fetch created, or the one passed in), so a value can never land on another request's TTL, scope or authorization context: when a later fetch has replaced the entry the value is dropped and the later fetch wins.
-
#authorization_fingerprint(header) ⇒ String?
A stable, non-reversible identifier of an Authorization header, so a cache entry can be bound to the credentials that produced it without keeping the credentials themselves around.
-
#authorization_header_value(headers) ⇒ String?
The Authorization value in a header collection, whatever the key's spelling: HTTP field names are case-insensitive and hosts configure them as strings or symbols (
Authorization:, 'AUTHORIZATION'). -
#bind_authorization_context(entry) ⇒ MCPClient::CachedResult
The same entry, bound to the current request's context.
-
#bump_cache_epoch ⇒ void
(call while holding cache_entries_mutex).
-
#bump_cache_generation(key) ⇒ void
(call while holding cache_entries_mutex).
- #cache_entries ⇒ Hash{Object => MCPClient::CachedResult}
- #cache_entries_mutex ⇒ Mutex
-
#cache_entry_for(result, value, now:, assume_zero: assume_zero_ttl?) ) ⇒ MCPClient::CachedResult
Build the entry for one result: an absent ttlMs counts as 0 on a 2026-07-28 server, and the entry remembers the authorization context of the request that produced it (transports that know it).
-
#cache_entry_fresh?(kind) ⇒ Boolean
Whether the entry a client-level slice came from is still fresh by its own hint — a lock-safe re-check (no probe, no host callable) for the moment a snapshot is handed out.
-
#cache_entry_hinted?(kind) ⇒ Boolean
Whether the entry holding a kind bounds its own freshness: a server that sent a ttlMs (or a 2026-07-28 server whose absent ttlMs means 0) says how long its list may be kept, empty or not.
-
#cache_entry_token(kind) ⇒ Object?
The identity of the entry currently holding the kind.
-
#cache_epoch(key = nil) ⇒ Array<Integer>
The invalidation generation of one cache key, so a response that was in flight while its own entry was invalidated is not written back — while an invalidation of another key (a resource updated during a tools/list) leaves it alone.
-
#cache_fresh?(kind) ⇒ Boolean
Whether the cached response for a kind may still be served.
-
#cache_generation(key) ⇒ Array<Integer>
(call while holding cache_entries_mutex).
-
#cache_info(kind, key = nil) ⇒ Hash?
The freshness hint recorded for an operation.
-
#cached_list_value(kind) ⇒ Object?
The cached list for a kind, when its entry is fresh and belongs to the current authorization context.
-
#clear_response_received_at ⇒ void
Forget a receipt time a request path is about to replace.
-
#clear_result_cache ⇒ void
Forget every cached result and hint (the connection, and with it the authorization context, is gone).
-
#discard_paginated_list(kind) ⇒ void
Forget everything cached for a paginated list: the entry that bounds it and the transport's own copy, so the next access really re-fetches from the first page.
-
#empty_list_copy?(copy) ⇒ Boolean
Whether it lists nothing (an Array, or a page Hash whose list member is empty).
-
#entry_for_current_params?(entry, context) ⇒ Boolean
Whether an entry was produced by a request carrying the effective parameters (host
_meta) the request being served would carry: the next request's for a :current lookup, the failed attempt's own when a stale fallback is judged. -
#entry_in_current_context?(entry, context: :current, kind: nil) ⇒ Boolean
Whether the entry may be served in the current authorization context.
-
#entry_matches_authorization?(entry, context, kind) ⇒ Boolean
Whether a privately scoped entry belongs to the context being served.
- #faraday_headers(headers) ⇒ Faraday::Utils::Headers
-
#fetching_list_page(kind, cursor) { ... } ⇒ Object
Run one page request of a paginated list, dropping the pages cached for that list when the server rejects the cursor it carried.
-
#forget_served_entries ⇒ void
Drop every note this thread holds for this transport (its connection is going away, so nothing will tag a slice with them).
-
#forget_transport_thread_state ⇒ void
Drop everything this transport left on the calling thread: its connection is going away, so none of it describes a request that will ever be made or a slice that will ever be tagged.
-
#fresh_list_value(kind) { ... } ⇒ Object?
The list a transport may serve for a kind without fetching: the value of a fresh entry in the current authorization context.
-
#hinted_list_value(kind) ⇒ Object?
The list a transport with no cache of its own may serve for a kind: only one the server itself bounded ("If ttlMs is positive, the client SHOULD consider the result fresh for that many milliseconds").
-
#invalid_cursor_error?(error) ⇒ Boolean
The JSON-RPC code a server answers a cursor it no longer accepts with (MCP pagination: an invalid cursor SHOULD be an -32602 Invalid params).
-
#invalidate_cache(kind) ⇒ void
Mark a kind stale: a change notification invalidates a still-fresh cache, so the kind must read as stale (not as "nothing known", which would let a concurrently snapshotted list be served) until the next fetch records a new hint.
-
#invalidate_cache_for_notification(method, params = nil) ⇒ void
Keep caches in step with the server's change notifications: a list change drops that list (and, for resources, every cached read), a resource update drops that resource's read.
-
#invalidate_read_cache(uri = nil) ⇒ void
Forget cached resources/read results: one URI, or all of them.
-
#list_cache_epoch(method) ⇒ Array<Integer>
The generation of that list's cache key.
-
#list_kind_for(method) ⇒ Symbol?
The cache kind that list fills.
-
#mixed_pages_placeholder(combined, now, contexts:, params:) ⇒ MCPClient::CachedResult
The entry to record for a combined list: the list itself, or — when its pages were fetched under differing credentials (a private list) or differing effective parameters (any list) — a stale placeholder that no context or parameters match.
-
#monotonic_now ⇒ Float
Monotonic clock, in seconds (stubbed in tests).
-
#note_legacy_served(kind) ⇒ Symbol
Note that this thread's last list of a kind carried no hint at all.
-
#note_response_received_at(now = monotonic_now) ⇒ void
Transports note the moment a response's bytes were in hand, before the notifications it carried are dispatched (a callback may run long, or send a nested request on this thread): the TTL runs from receipt (MCP 2026-07-28 caching, "Freshness Calculation"), not from the end of that processing.
-
#note_served_entry(kind, entry) ⇒ void
Remember, per thread, the entry a list of a kind was last served or attached from — its identity and the parameters it is bound to — so a cache built on top (the client's) can tie its slice to that very entry.
-
#on_cache_invalidation {|method, params| ... } ⇒ void
A host layered above the transport (MCPClient::Client) keeps caches of its own, and they must be gone before a subscription listener runs — the listener is delivered right after this returns, while the host's own notification callback runs last, after the delivery, so that host code cannot hold the delivery up.
-
#private_entry_for_current_context(kind) ⇒ MCPClient::CachedResult?
The entry for a kind, after making sure a privately scoped one still belongs to the current authorization context (transports that know their context re-check it here; a changed context drops the entry).
-
#prune_read_entries(now:) ⇒ void
Drop expired reads and, past MAX_CACHED_READS, the oldest ones, so a long-lived connection does not accumulate every URI ever read.
- #read_cache_key(uri) ⇒ String
-
#read_resource_with_cache(uri) {|uri| ... } ⇒ Array<MCPClient::ResourceContent>
Serve a cached resources/read while fresh; otherwise fetch, and cache the contents unless they came from a multi round-trip retry ("results produced by retrying a request through the multi round-trip requests mechanism MUST NOT be cached").
-
#record_cache_hint(kind, result, value = nil, epoch: nil, received_at: nil) ⇒ MCPClient::CachedResult
Record the freshness hint of one result.
-
#record_list_cache_hint(method, page_results, received_ats = nil, contexts: nil, params: nil, epoch: nil) ⇒ void
Called by the paginated list helper with the raw page results.
-
#record_paginated_cache_hint(kind, page_results, value = nil, received_ats: nil, contexts: nil, params: nil, epoch: nil) ⇒ MCPClient::CachedResult?
Record the hint of an auto-paginated list from its pages (shortest TTL wins).
-
#recorded_entries_key ⇒ Symbol
The thread-local key of this server's recorded entries.
-
#release_serving_request_meta ⇒ void
A cached value was served, so the lookup that led here leads to no request of its own: the metadata held for that request is dropped rather than sent, some time later, by another one.
-
#remember_recorded_entry(kind, entry) ⇒ MCPClient::CachedResult
Stamp the entry this thread's fetch recorded with a fresh identity and remember it, so the list the same fetch converts afterwards can be attached to its own entry and to no other (the thread keeps only the bare identity, never the entry or its list).
-
#response_received_at(since: nil) ⇒ Float
The receipt time of the response this thread just got, or the current time when none was noted (a stubbed transport) or the noted one is older than the request that asks (a leftover from an earlier request).
-
#response_received_key ⇒ Symbol
This transport's thread-local key for the receipt time.
-
#sent_authorization_known? ⇒ Boolean
Whether what the request behind an entry went out with is known at all.
-
#served_entries_key ⇒ Symbol
The thread-local key of this server's served entries.
-
#stale_fallback_for(kind, entry, context: :current) ⇒ Object?
The stale copy that may be served when a re-fetch fails: the value of the entry captured before the re-fetch, and only when that very entry belongs to the authorization context (checked against the credentials the failed request actually used, when the caller knows them) — an entry installed meanwhile by another request never vouches for it.
-
#stale_list_entry(kind) ⇒ MCPClient::CachedResult?
The entry whose (possibly stale) list may be served when a re-fetch fails; the entry itself, so the fallback is judged by the entry that supplied the value and not by whatever entry is installed by then.
-
#stale_list_value(kind) ⇒ Object?
The list recorded for a kind whatever its freshness: the candidate for serving stale when a re-fetch fails (#stale_fallback_for decides).
-
#store_read_entry(key, entry, replacing:, epoch:, now:) ⇒ void
Store a read's entry, or drop the slot it replaces.
-
#take_served_entry(kind) ⇒ Array(Object, String)?
Take the note left for a kind: it is written for the one cache above this transport that tags its slice with it, so reading it consumes it.
-
#transport_thread_local_keys ⇒ Array<Symbol>
The thread-local slots a transport owns, each keyed by its own
object_id: the notes of the entries it served and recorded, the receipt time, the credentials and effective parameters of the request this thread last sent through it, and its multi round-trip marker.
Class Method Details
.authorization_header_value(headers) ⇒ String?
796 797 798 799 800 801 802 803 804 805 806 807 808 809 |
# File 'lib/mcp_client/result_caching.rb', line 796 def self.(headers) return nil if headers.nil? # Faraday's own table already holds one entry per field name. return headers['Authorization'] if headers.is_a?(Faraday::Utils::Headers) return headers['Authorization'] || headers['authorization'] unless headers.respond_to?(:each_pair) # A plain Hash can hold several spellings at once (a configured # `authorization:` and an OAuth provider's canonical `Authorization`). # Faraday copies them into a case-insensitive table, so the request # carries what the last of them writes — and so must the fingerprint. value = nil headers.each_pair { |key, header| value = header if key.to_s.casecmp?('authorization') } value end |
.faraday_headers(headers) ⇒ Faraday::Utils::Headers
The header table a Faraday request built from these headers carries:
field names are case-insensitive there, so several spellings of one
header collapse into a single entry and a later write of any spelling
(an OAuth provider's canonical Authorization) replaces it rather
than leaving the older one behind.
The table is always a detached copy: its caller hands it to the OAuth provider, which writes the Authorization it would apply into it, and that must never reach the headers the transport builds its requests from (a probed token would outlive the credentials it came from).
823 824 825 826 827 828 829 |
# File 'lib/mcp_client/result_caching.rb', line 823 def self.faraday_headers(headers) # Faraday's own table dups its case-insensitive name index with it. return headers.dup if headers.is_a?(Faraday::Utils::Headers) return Faraday::Utils::Headers.new unless headers.respond_to?(:each_pair) Faraday::Utils::Headers.new(headers.to_h) end |
Instance Method Details
#assume_zero_ttl? ⇒ Boolean
Returns whether an absent ttlMs means "immediately stale" (2026-07-28 servers).
234 235 236 |
# File 'lib/mcp_client/result_caching.rb', line 234 def assume_zero_ttl? respond_to?(:modern?) && modern? end |
#attach_list_value(kind, value, entry: nil) ⇒ Boolean
Attach the list a request produced to the very entry that request recorded (the one this thread's fetch created, or the one passed in), so a value can never land on another request's TTL, scope or authorization context: when a later fetch has replaced the entry the value is dropped and the later fetch wins.
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 |
# File 'lib/mcp_client/result_caching.rb', line 637 def attach_list_value(kind, value, entry: nil) recorded = Thread.current[recorded_entries_key] token = entry ? entry.fetch_token : recorded&.delete(kind) # Nothing of this transport's is outstanding on this thread any more. Thread.current[recorded_entries_key] = nil if recorded && recorded.empty? note_served_entry(kind, nil) # Nothing recorded for this fetch: a legacy list without a hint, which # the transport keeps until a list_changed notification. A recorded # hint whose entry is gone or replaced is a rejection (false). return note_legacy_served(kind) unless token context = respond_to?(:request_authorization_context, true) ? : nil cache_entries_mutex.synchronize do entry = cache_entries[kind] return false unless entry && entry.fetch_token.equal?(token) return false if entry.cache_scope == 'private' && entry. != context # The cache keeps its own copy: what the fetch returns to its caller # may be changed freely. entry.value = MCPClient::DeepCopy.copy(value) note_served_entry(kind, entry) true end end |
#authorization_fingerprint(header) ⇒ String?
A stable, non-reversible identifier of an Authorization header, so a cache entry can be bound to the credentials that produced it without keeping the credentials themselves around.
843 844 845 846 847 |
# File 'lib/mcp_client/result_caching.rb', line 843 def (header) return nil if header.nil? Digest::SHA256.hexdigest(header.to_s) end |
#authorization_header_value(headers) ⇒ String?
The Authorization value in a header collection, whatever the key's
spelling: HTTP field names are case-insensitive and hosts configure
them as strings or symbols (Authorization:, 'AUTHORIZATION').
789 790 791 |
# File 'lib/mcp_client/result_caching.rb', line 789 def (headers) MCPClient::ResultCaching.(headers) end |
#bind_authorization_context(entry) ⇒ MCPClient::CachedResult
Returns the same entry, bound to the current request's context.
211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/mcp_client/result_caching.rb', line 211 def (entry) if respond_to?(:request_authorization_context, true) # An entry whose request nothing could record belongs to no context # rather than to the anonymous one: the two are the same `nil`, and # filing an authenticated result as anonymous is what hands it to the # next caller who sends no credentials at all. entry. = ? : MCPClient::CachedResult::UNKNOWN_CONTEXT end entry.params_fingerprint = request_params_fingerprint if respond_to?(:request_params_fingerprint, true) entry end |
#bump_cache_epoch ⇒ void
This method returns an undefined value.
Returns (call while holding cache_entries_mutex).
150 151 152 153 154 155 |
# File 'lib/mcp_client/result_caching.rb', line 150 def bump_cache_epoch @cache_epoch = (@cache_epoch || 0) + 1 # Every key's generation moved with the base: the per-key counts have # nothing left to add. @cache_generations = nil end |
#bump_cache_generation(key) ⇒ void
This method returns an undefined value.
Returns (call while holding cache_entries_mutex).
159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/mcp_client/result_caching.rb', line 159 def bump_cache_generation(key) @cache_generations ||= {} @cache_generations[key] = (@cache_generations[key] || 0) + 1 return unless key.is_a?(String) && @cache_generations.count { |k, _| k.is_a?(String) } > MAX_READ_GENERATIONS # Too many URIs to remember one by one: fold them into the count that # invalidates every read. The shared count jumps past the largest # per-URI count it absorbs, so no key's generation stands still or # goes back — a read still in flight cannot be stored against a # generation this fold already left behind. absorbed = @cache_generations.select { |k, _| k.is_a?(String) }.values.max || 0 @cache_generations.delete_if { |k, _| k.is_a?(String) } @cache_generations[:'read:*'] = (@cache_generations[:'read:*'] || 0) + absorbed + 1 end |
#cache_entries ⇒ Hash{Object => MCPClient::CachedResult}
99 100 101 |
# File 'lib/mcp_client/result_caching.rb', line 99 def cache_entries @cache_entries || CACHE_INIT_LOCK.synchronize { @cache_entries ||= {} } end |
#cache_entries_mutex ⇒ Mutex
104 105 106 |
# File 'lib/mcp_client/result_caching.rb', line 104 def cache_entries_mutex @cache_entries_mutex || CACHE_INIT_LOCK.synchronize { @cache_entries_mutex ||= Mutex.new } end |
#cache_entry_for(result, value, now:, assume_zero: assume_zero_ttl?) ) ⇒ MCPClient::CachedResult
Build the entry for one result: an absent ttlMs counts as 0 on a 2026-07-28 server, and the entry remembers the authorization context of the request that produced it (transports that know it).
204 205 206 207 |
# File 'lib/mcp_client/result_caching.rb', line 204 def cache_entry_for(result, value, now:, assume_zero: assume_zero_ttl?) entry = MCPClient::CachedResult.from_result(result, value, now: now, assume_zero: assume_zero) (entry) end |
#cache_entry_fresh?(kind) ⇒ Boolean
Whether the entry a client-level slice came from is still fresh by its own hint — a lock-safe re-check (no probe, no host callable) for the moment a snapshot is handed out. No entry means nothing bounds it.
398 399 400 401 402 403 |
# File 'lib/mcp_client/result_caching.rb', line 398 def cache_entry_fresh?(kind) cache_entries_mutex.synchronize do entry = cache_entries[kind] entry.nil? || (!entry.value.nil? && entry.fresh?(now: monotonic_now)) end end |
#cache_entry_hinted?(kind) ⇒ Boolean
Whether the entry holding a kind bounds its own freshness: a server that sent a ttlMs (or a 2026-07-28 server whose absent ttlMs means 0) says how long its list may be kept, empty or not. Without a hint the client's own heuristic applies instead, and an empty list is asked for again rather than kept for the life of the connection.
412 413 414 415 416 417 |
# File 'lib/mcp_client/result_caching.rb', line 412 def cache_entry_hinted?(kind) cache_entries_mutex.synchronize do entry = cache_entries[kind] !entry.nil? && !entry.value.nil? && entry.hint? end end |
#cache_entry_token(kind) ⇒ Object?
Returns the identity of the entry currently holding the kind.
421 422 423 424 425 426 427 |
# File 'lib/mcp_client/result_caching.rb', line 421 def cache_entry_token(kind) # A placeholder (an invalidation, a cleanup) identifies nothing. cache_entries_mutex.synchronize do entry = cache_entries[kind] entry&.value.nil? ? nil : entry.fetch_token end end |
#cache_epoch(key = nil) ⇒ Array<Integer>
The invalidation generation of one cache key, so a response that was in flight while its own entry was invalidated is not written back — while an invalidation of another key (a resource updated during a tools/list) leaves it alone. Every key shares a base bumped when the whole cache goes (cleanup, a new authorization context); each key keeps its own count, and every read shares one more.
The three are compared side by side rather than added up: a cleanup bumps the base and clears the other counts, so a sum would carry a key an invalidation had already bumped (0 + 1) straight through the cleanup unchanged (1 + 0), and the response of a request the cleanup overtook would install itself as fresh.
122 123 124 |
# File 'lib/mcp_client/result_caching.rb', line 122 def cache_epoch(key = nil) cache_entries_mutex.synchronize { cache_generation(key) } end |
#cache_fresh?(kind) ⇒ Boolean
Whether the cached response for a kind may still be served. No entry (nothing cached yet) or no hint (older server) means the client's own heuristic applies: cache until a change notification.
454 455 456 457 458 459 460 |
# File 'lib/mcp_client/result_caching.rb', line 454 def cache_fresh?(kind) entry = private_entry_for_current_context(kind) return true if entry.nil? return false if LIST_VALUE_KINDS.include?(kind) && entry.value.nil? entry.fresh?(now: monotonic_now) end |
#cache_generation(key) ⇒ Array<Integer>
Returns (call while holding cache_entries_mutex).
139 140 141 142 143 144 145 146 147 |
# File 'lib/mcp_client/result_caching.rb', line 139 def cache_generation(key) base = @cache_epoch || 0 return [base] if key.nil? gens = @cache_generations || {} own = gens[key] || 0 reads = key.is_a?(String) && key.start_with?('read:') ? (gens[:'read:*'] || 0) : 0 [base, own, reads] end |
#cache_info(kind, key = nil) ⇒ Hash?
The freshness hint recorded for an operation.
706 707 708 709 |
# File 'lib/mcp_client/result_caching.rb', line 706 def cache_info(kind, key = nil) entry = cache_entries_mutex.synchronize { cache_entries[kind == :read ? read_cache_key(key) : kind] } entry&.to_info(now: monotonic_now) end |
#cached_list_value(kind) ⇒ Object?
The cached list for a kind, when its entry is fresh and belongs to the current authorization context.
666 667 668 669 670 671 672 |
# File 'lib/mcp_client/result_caching.rb', line 666 def cached_list_value(kind) entry = private_entry_for_current_context(kind) return nil unless entry&.value && entry.fresh?(now: monotonic_now) entry.value end |
#clear_response_received_at ⇒ void
This method returns an undefined value.
Forget a receipt time a request path is about to replace.
75 76 77 |
# File 'lib/mcp_client/result_caching.rb', line 75 def clear_response_received_at Thread.current[response_received_key] = nil end |
#clear_result_cache ⇒ void
This method returns an undefined value.
Forget every cached result and hint (the connection, and with it the authorization context, is gone).
766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 |
# File 'lib/mcp_client/result_caching.rb', line 766 def clear_result_cache now = monotonic_now cache_entries_mutex.synchronize do # Lists stay known-and-stale (a client-level cache built from the old # connection must not read an empty entry as "fresh"); reads are # simply forgotten, they are only ever served with a value. cache_entries.delete_if { |key, _| key.is_a?(String) } PLACEHOLDER_KINDS.each { |kind| cache_entries[kind] ||= nil } cache_entries.each_key do |key| # Whoever still holds the replaced object (a re-fetch in flight) # must not serve its list either. cache_entries[key]&.value = nil cache_entries[key] = MCPClient::CachedResult.stale(now: now, like: cache_entries[key]) end bump_cache_epoch end end |
#discard_paginated_list(kind) ⇒ void
This method returns an undefined value.
Forget everything cached for a paginated list: the entry that bounds it and the transport's own copy, so the next access really re-fetches from the first page.
758 759 760 761 |
# File 'lib/mcp_client/result_caching.rb', line 758 def discard_paginated_list(kind) invalidate_cache(kind) invalidate_list_cache(kind) if respond_to?(:invalidate_list_cache, true) end |
#empty_list_copy?(copy) ⇒ Boolean
Returns whether it lists nothing (an Array, or a page Hash whose list member is empty).
515 516 517 518 519 520 521 |
# File 'lib/mcp_client/result_caching.rb', line 515 def empty_list_copy?(copy) case copy when Array then copy.empty? when Hash then copy.values.any?(Array) && copy.values.grep(Array).all?(&:empty?) else false end end |
#entry_for_current_params?(entry, context) ⇒ Boolean
Whether an entry was produced by a request carrying the effective
parameters (host _meta) the request being served would carry: the
next request's for a :current lookup, the failed attempt's own when a
stale fallback is judged. A result is never served across them,
whatever its scope.
612 613 614 615 616 617 618 619 620 621 622 623 624 625 |
# File 'lib/mcp_client/result_caching.rb', line 612 def entry_for_current_params?(entry, context) return false if entry.params_fingerprint.equal?(MCPClient::CachedResult::MIXED_PARAMS) return true unless entry.params_fingerprint && respond_to?(:current_params_fingerprint, true) expected = context == :current ? current_params_fingerprint : request_params_fingerprint # A failed attempt that never built its request noted no parameters: # it matches no entry, whatever the previous request on this thread # carried. # Reading the next request's parameters evaluates a host request_meta # callable, and the transport holds that evaluation for the request # this lookup leads to (or for the probe that models it); the caller # drops it once the entry is served instead. expected.is_a?(String) && entry.params_fingerprint == expected end |
#entry_in_current_context?(entry, context: :current, kind: nil) ⇒ Boolean
Returns whether the entry may be served in the current authorization context.
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/mcp_client/result_caching.rb', line 561 def entry_in_current_context?(entry, context: :current, kind: nil) return false if entry && !entry_for_current_params?(entry, context) # The metadata this lookup evaluated stays held: an entry that belongs # to the context may still be too stale to serve, and the request that # then goes out carries the very evaluation the decision was made on. # {#release_serving_request_meta} drops it once a value really is # served instead. (entry, context, kind) rescue StandardError # The lookup aborted — the authorization probe raised, an OAuth # refresh failed — so it builds no request at all. Whatever evaluation # of the host's request_meta it was holding for that request would # otherwise sit on this thread and be sent, much later, by an # unrelated request: the next request reads the host afresh instead. raise end |
#entry_matches_authorization?(entry, context, kind) ⇒ Boolean
Returns whether a privately scoped entry belongs to the context being served.
592 593 594 595 596 597 598 599 600 601 602 |
# File 'lib/mcp_client/result_caching.rb', line 592 def (entry, context, kind) return true unless entry&.cache_scope == 'private' && respond_to?(:current_authorization_context, true) # An entry that belongs to no context matches none: a private list # whose pages were fetched under different credentials, or one whose # request nothing could record. Both are sentinels rather than a # header, so neither can be equal to a context being served. return false unless entry..nil? || entry..is_a?(String) context = (kind) if context == :current entry. == context end |
#faraday_headers(headers) ⇒ Faraday::Utils::Headers
834 835 836 |
# File 'lib/mcp_client/result_caching.rb', line 834 def faraday_headers(headers) MCPClient::ResultCaching.faraday_headers(headers) end |
#fetching_list_page(kind, cursor) { ... } ⇒ Object
Run one page request of a paginated list, dropping the pages cached for that list when the server rejects the cursor it carried. A cursor names a position in one sequence of pages: once the server has forgotten it, the first page cached from that sequence is gone with it, and serving that page again would hand the caller the same dead cursor to follow. A rejection of the first page's request carries no cursor and says nothing about the cache, so it leaves it alone.
744 745 746 747 748 749 750 751 |
# File 'lib/mcp_client/result_caching.rb', line 744 def fetching_list_page(kind, cursor) yield rescue MCPClient::Errors::ServerError => e raise unless kind && cursor && invalid_cursor_error?(e) discard_paginated_list(kind) raise end |
#forget_served_entries ⇒ void
This method returns an undefined value.
Drop every note this thread holds for this transport (its connection is going away, so nothing will tag a slice with them).
359 360 361 |
# File 'lib/mcp_client/result_caching.rb', line 359 def forget_served_entries Thread.current[served_entries_key] = nil end |
#forget_transport_thread_state ⇒ void
This method returns an undefined value.
Drop everything this transport left on the calling thread: its connection is going away, so none of it describes a request that will ever be made or a slice that will ever be tagged. A worker thread that creates and discards transports would otherwise accumulate one entry per slot per transport for its whole life.
389 390 391 |
# File 'lib/mcp_client/result_caching.rb', line 389 def forget_transport_thread_state transport_thread_local_keys.each { |key| Thread.current[key] = nil } end |
#fresh_list_value(kind) { ... } ⇒ Object?
The list a transport may serve for a kind without fetching: the value of a fresh entry in the current authorization context. With no entry at all (nothing recorded yet) the transport's own copy, given by the block, stands in; a hint without a value never lets that copy through.
480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 |
# File 'lib/mcp_client/result_caching.rb', line 480 def fresh_list_value(kind) entry = private_entry_for_current_context(kind) if entry.nil? note_legacy_served(kind) # The transport's own copy stands in for a list an older server put # no hint on -- unless it is empty: an empty unhinted list is asked # for again, the way the client's own cache treats it, rather than # kept for the life of the connection. copy = block_given? ? yield : nil return empty_list_copy?(copy) ? nil : copy end # An invalidation (a list_changed notification, a cleanup) that lands # after the lookup — the authorization probe makes that a wide window — # replaces the slot but leaves this reference intact: the copy is made # under the lock, and only while the entry is still the one the map # holds and still fresh. copy = cache_entries_mutex.synchronize do next nil unless cache_entries[kind].equal?(entry) && entry.value && entry.fresh?(now: monotonic_now) # An empty list an older server put no hint on (an entry kept until # a change notification) is asked for again, the way the client's # own cache treats it, rather than kept for the life of the connection. next nil if entry.ttl_ms.nil? && empty_list_copy?(entry.value) MCPClient::DeepCopy.copy(entry.value) end return nil unless copy note_served_entry(kind, entry) copy end |
#hinted_list_value(kind) ⇒ Object?
The list a transport with no cache of its own may serve for a kind: only one the server itself bounded ("If ttlMs is positive, the client SHOULD consider the result fresh for that many milliseconds"). A list without a hint is left to the client's own cache, which asks again for an empty one instead of keeping it for the life of the connection.
469 470 471 |
# File 'lib/mcp_client/result_caching.rb', line 469 def hinted_list_value(kind) cache_entry_hinted?(kind) ? fresh_list_value(kind) : nil end |
#invalid_cursor_error?(error) ⇒ Boolean
The JSON-RPC code a server answers a cursor it no longer accepts with (MCP pagination: an invalid cursor SHOULD be an -32602 Invalid params).
729 730 731 |
# File 'lib/mcp_client/result_caching.rb', line 729 def invalid_cursor_error?(error) error.is_a?(MCPClient::Errors::ServerError) && error.code == MCPClient::Errors::Codes::INVALID_PARAMS end |
#invalidate_cache(kind) ⇒ void
This method returns an undefined value.
Mark a kind stale: a change notification invalidates a still-fresh cache, so the kind must read as stale (not as "nothing known", which would let a concurrently snapshotted list be served) until the next fetch records a new hint.
717 718 719 720 721 722 723 |
# File 'lib/mcp_client/result_caching.rb', line 717 def invalidate_cache(kind) now = monotonic_now cache_entries_mutex.synchronize do cache_entries[kind] = MCPClient::CachedResult.stale(now: now, like: cache_entries[kind]) bump_cache_generation(kind) end end |
#invalidate_cache_for_notification(method, params = nil) ⇒ void
This method returns an undefined value.
Keep caches in step with the server's change notifications: a list change drops that list (and, for resources, every cached read), a resource update drops that resource's read.
985 986 987 988 989 990 991 992 993 994 995 996 997 |
# File 'lib/mcp_client/result_caching.rb', line 985 def invalidate_cache_for_notification(method, params = nil) kinds = LIST_CHANGE_NOTIFICATIONS[method] if kinds kinds.each do |kind| invalidate_cache(kind) invalidate_list_cache(kind) if respond_to?(:invalidate_list_cache, true) invalidate_read_cache if kind == :resources end elsif method == 'notifications/resources/updated' uri = params.is_a?(Hash) ? params['uri'] : nil invalidate_read_cache(uri) if uri.is_a?(String) end end |
#invalidate_read_cache(uri = nil) ⇒ void
This method returns an undefined value.
Forget cached resources/read results: one URI, or all of them.
852 853 854 855 856 857 858 859 860 861 862 |
# File 'lib/mcp_client/result_caching.rb', line 852 def invalidate_read_cache(uri = nil) cache_entries_mutex.synchronize do if uri cache_entries.delete(read_cache_key(uri)) bump_cache_generation(read_cache_key(uri)) else cache_entries.delete_if { |key, _| key.is_a?(String) && key.start_with?('read:') } bump_cache_generation(:'read:*') end end end |
#list_cache_epoch(method) ⇒ Array<Integer>
Returns the generation of that list's cache key.
128 129 130 |
# File 'lib/mcp_client/result_caching.rb', line 128 def list_cache_epoch(method) cache_epoch(list_kind_for(method)) end |
#list_kind_for(method) ⇒ Symbol?
Returns the cache kind that list fills.
134 135 136 |
# File 'lib/mcp_client/result_caching.rb', line 134 def list_kind_for(method) LIST_METHOD_KINDS[method] end |
#mixed_pages_placeholder(combined, now, contexts:, params:) ⇒ MCPClient::CachedResult
The entry to record for a combined list: the list itself, or — when its pages were fetched under differing credentials (a private list) or differing effective parameters (any list) — a stale placeholder that no context or parameters match.
286 287 288 289 290 291 292 293 294 295 |
# File 'lib/mcp_client/result_caching.rb', line 286 def mixed_pages_placeholder(combined, now, contexts:, params:) mixed = combined.cache_scope == 'private' && contexts && contexts.uniq.size > 1 mixed_params = params && params.uniq.size > 1 return combined unless mixed || mixed_params placeholder = MCPClient::CachedResult.stale(now: now, like: combined) placeholder. = MCPClient::CachedResult::MIXED_CONTEXT if mixed placeholder.params_fingerprint = MCPClient::CachedResult::MIXED_PARAMS if mixed_params placeholder end |
#monotonic_now ⇒ Float
Returns monotonic clock, in seconds (stubbed in tests).
58 59 60 |
# File 'lib/mcp_client/result_caching.rb', line 58 def monotonic_now Process.clock_gettime(Process::CLOCK_MONOTONIC) end |
#note_legacy_served(kind) ⇒ Symbol
Note that this thread's last list of a kind carried no hint at all.
334 335 336 337 |
# File 'lib/mcp_client/result_caching.rb', line 334 def note_legacy_served(kind) (Thread.current[served_entries_key] ||= {})[kind] = [LEGACY_ENTRY, nil] LEGACY_ENTRY end |
#note_response_received_at(now = monotonic_now) ⇒ void
This method returns an undefined value.
Transports note the moment a response's bytes were in hand, before the notifications it carried are dispatched (a callback may run long, or send a nested request on this thread): the TTL runs from receipt (MCP 2026-07-28 caching, "Freshness Calculation"), not from the end of that processing. The value is per thread and per transport, consumed once.
69 70 71 |
# File 'lib/mcp_client/result_caching.rb', line 69 def note_response_received_at(now = monotonic_now) Thread.current[response_received_key] = now end |
#note_served_entry(kind, entry) ⇒ void
This method returns an undefined value.
Remember, per thread, the entry a list of a kind was last served or attached from — its identity and the parameters it is bound to — so a cache built on top (the client's) can tie its slice to that very entry.
327 328 329 |
# File 'lib/mcp_client/result_caching.rb', line 327 def note_served_entry(kind, entry) (Thread.current[served_entries_key] ||= {})[kind] = entry && [entry.fetch_token, entry.params_fingerprint] end |
#on_cache_invalidation {|method, params| ... } ⇒ void
This method returns an undefined value.
A host layered above the transport (MCPClient::Client) keeps caches of its own, and they must be gone before a subscription listener runs — the listener is delivered right after this returns, while the host's own notification callback runs last, after the delivery, so that host code cannot hold the delivery up.
975 976 977 |
# File 'lib/mcp_client/result_caching.rb', line 975 def on_cache_invalidation(&block) @cache_invalidation_callback = block end |
#private_entry_for_current_context(kind) ⇒ MCPClient::CachedResult?
The entry for a kind, after making sure a privately scoped one still belongs to the current authorization context (transports that know their context re-check it here; a changed context drops the entry).
545 546 547 548 549 550 551 552 |
# File 'lib/mcp_client/result_caching.rb', line 545 def private_entry_for_current_context(kind) entry = cache_entries_mutex.synchronize { cache_entries[kind] } return entry if entry_in_current_context?(entry, kind: kind) # Another context's private entry reads as known-and-stale, never as # "nothing cached" (which would count as fresh) and never as a value. MCPClient::CachedResult.stale(now: monotonic_now) end |
#prune_read_entries(now:) ⇒ void
This method returns an undefined value.
Drop expired reads and, past MAX_CACHED_READS, the oldest ones, so a long-lived connection does not accumulate every URI ever read. (call while holding cache_entries_mutex)
956 957 958 959 960 961 962 963 964 965 |
# File 'lib/mcp_client/result_caching.rb', line 956 def prune_read_entries(now:) reads = cache_entries.select { |k, _| k.is_a?(String) && k.start_with?('read:') } reads.each { |k, entry| cache_entries.delete(k) unless entry.fresh?(now: now) } reads = cache_entries.select { |k, _| k.is_a?(String) && k.start_with?('read:') } while reads.size >= MAX_CACHED_READS oldest = reads.min_by { |_, entry| entry.received_at }.first cache_entries.delete(oldest) reads.delete(oldest) end end |
#read_cache_key(uri) ⇒ String
866 867 868 |
# File 'lib/mcp_client/result_caching.rb', line 866 def read_cache_key(uri) "read:#{uri}" end |
#read_resource_with_cache(uri) {|uri| ... } ⇒ Array<MCPClient::ResourceContent>
Serve a cached resources/read while fresh; otherwise fetch, and cache the contents unless they came from a multi round-trip retry ("results produced by retrying a request through the multi round-trip requests mechanism MUST NOT be cached").
879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 |
# File 'lib/mcp_client/result_caching.rb', line 879 def read_resource_with_cache(uri) # One immutable URI names the request, its key and its entry: a host # that rewrites the string it passed while the read is under way (a # concurrent caller, a callback) must not have B's contents filed # under A ("a cached response MUST NOT be served across different # request parameters"). uri = -uri.to_s key = read_cache_key(uri) cached = private_entry_for_current_context(key) # An invalidation (a resources/updated notification, a cleanup) that # lands after the lookup takes the entry out of the map but leaves # this reference intact: the copy is made under the lock, and only # while the entry is still the one the map holds. served = cached && cache_entries_mutex.synchronize do next nil unless cache_entries[key].equal?(cached) && cached.value && cached.fresh?(now: monotonic_now) cached.value.map(&:dup) end if served return served end epoch = cache_epoch(key) started = monotonic_now result = yield(uri) # The TTL runs from receipt — before the response's notifications were # dispatched — not from the end of the conversion below. received_at = response_received_at(since: started) unless result.is_a?(Hash) raise MCPClient::Errors::TransportError, "Invalid resources/read response: expected an object, got #{result.class}" end # Projecting `contents` out of an unfinished answer would present it as # an empty successful read -- and cache it. The guard runs before both. require_complete_result!(result, 'resources/read') contents = (result['contents'] || []).map { |content| MCPClient::ResourceContent.from_json(content) } entry = cache_entry_for(result, contents, now: received_at) # A read is cached only on an explicit, positive ttlMs: "if ttlMs is # absent, clients SHOULD assume 0" — and reads were never cached # before this revision, so a legacy server keeps that behaviour; a # result that is stale on arrival would only take up memory. A result # reached through a multi round-trip retry MUST NOT be cached either, # nor one whose entry was invalidated while the request was in flight. store_read_entry(key, entry, replacing: cached, epoch: epoch, now: received_at) # The caller gets its own copies; the cached ones stay untouched. contents.map(&:dup) end |
#record_cache_hint(kind, result, value = nil, epoch: nil, received_at: nil) ⇒ MCPClient::CachedResult
Record the freshness hint of one result.
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/mcp_client/result_caching.rb', line 181 def record_cache_hint(kind, result, value = nil, epoch: nil, received_at: nil) now = received_at || response_received_at entry = cache_entry_for(result, value, now: now) cache_entries_mutex.synchronize do if epoch && epoch != cache_generation(kind) # Invalidated while in flight: whatever is installed now (the # invalidation's placeholder, or a newer fetch) stays; this result # is reported stale and never stored. entry = MCPClient::CachedResult.stale(now: now, like: entry) else cache_entries[kind] = entry end end remember_recorded_entry(kind, entry) end |
#record_list_cache_hint(method, page_results, received_ats = nil, contexts: nil, params: nil, epoch: nil) ⇒ void
This method returns an undefined value.
Called by the paginated list helper with the raw page results.
441 442 443 444 445 446 447 |
# File 'lib/mcp_client/result_caching.rb', line 441 def record_list_cache_hint(method, page_results, received_ats = nil, contexts: nil, params: nil, epoch: nil) kind = LIST_METHOD_KINDS[method] return unless kind record_paginated_cache_hint(kind, page_results, received_ats: received_ats, contexts: contexts, params: params, epoch: epoch) end |
#record_paginated_cache_hint(kind, page_results, value = nil, received_ats: nil, contexts: nil, params: nil, epoch: nil) ⇒ MCPClient::CachedResult?
Record the hint of an auto-paginated list from its pages (shortest TTL wins).
246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 |
# File 'lib/mcp_client/result_caching.rb', line 246 def record_paginated_cache_hint(kind, page_results, value = nil, received_ats: nil, contexts: nil, params: nil, epoch: nil) now = monotonic_now entries = page_results.each_with_index.map do |result, index| # A bare array page (accepted for compatibility) carries no hint: # on a modern server that means ttlMs 0. MCPClient::CachedResult.from_result(result.is_a?(Hash) ? result : {}, nil, now: (received_ats && received_ats[index]) || now, assume_zero: assume_zero_ttl?) end return nil if entries.empty? combined = (MCPClient::CachedResult.combine(entries, value, now: now)) # A list invalidated while it was being fetched is already stale and # never replaces what is installed now (the invalidation's placeholder # or a newer fetch); a private list whose pages were fetched under # different credentials belongs to no single context, and a list whose # pages were fetched under differing effective parameters (the host's # request_meta changed between pages) matches no request's parameters, # whatever its scope. combined = mixed_pages_placeholder(combined, now, contexts: contexts, params: params) cache_entries_mutex.synchronize do if epoch && epoch != cache_generation(kind) combined = MCPClient::CachedResult.stale(now: now, like: combined) else cache_entries[kind] = combined end end remember_recorded_entry(kind, combined) end |
#recorded_entries_key ⇒ Symbol
Returns the thread-local key of this server's recorded entries.
317 318 319 |
# File 'lib/mcp_client/result_caching.rb', line 317 def recorded_entries_key :"mcp_client_recorded_entries_#{object_id}" end |
#release_serving_request_meta ⇒ void
This method returns an undefined value.
A cached value was served, so the lookup that led here leads to no request of its own: the metadata held for that request is dropped rather than sent, some time later, by another one.
584 585 586 |
# File 'lib/mcp_client/result_caching.rb', line 584 def if respond_to?(:release_held_request_meta, true) end |
#remember_recorded_entry(kind, entry) ⇒ MCPClient::CachedResult
Stamp the entry this thread's fetch recorded with a fresh identity and remember it, so the list the same fetch converts afterwards can be attached to its own entry and to no other (the thread keeps only the bare identity, never the entry or its list).
304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/mcp_client/result_caching.rb', line 304 def remember_recorded_entry(kind, entry) entry.fetch_token = Object.new # Only a list attaches its value afterwards and takes its identity # back out again: remembering any other kind (a discovery, which is # never attached) would leave a token on the thread for the life of # the thread, one per transport a long-lived worker ever built. return entry unless LIST_VALUE_KINDS.include?(kind) (Thread.current[recorded_entries_key] ||= {})[kind] = entry.fetch_token entry end |
#response_received_at(since: nil) ⇒ Float
The receipt time of the response this thread just got, or the current time when none was noted (a stubbed transport) or the noted one is older than the request that asks (a leftover from an earlier request).
84 85 86 87 88 89 90 91 |
# File 'lib/mcp_client/result_caching.rb', line 84 def response_received_at(since: nil) noted = Thread.current[response_received_key] Thread.current[response_received_key] = nil return monotonic_now unless noted return monotonic_now if since && noted < since noted end |
#response_received_key ⇒ Symbol
Returns this transport's thread-local key for the receipt time.
94 95 96 |
# File 'lib/mcp_client/result_caching.rb', line 94 def response_received_key :"mcp_response_received_at_#{object_id}" end |
#sent_authorization_known? ⇒ Boolean
Whether what the request behind an entry went out with is known at all. A transport that applies its own headers and nothing else always knows; HttpTransportBase::CacheSupport answers for a connection carrying host middleware.
229 230 231 |
# File 'lib/mcp_client/result_caching.rb', line 229 def true end |
#served_entries_key ⇒ Symbol
Returns the thread-local key of this server's served entries.
430 431 432 |
# File 'lib/mcp_client/result_caching.rb', line 430 def served_entries_key :"mcp_client_served_entries_#{object_id}" end |
#stale_fallback_for(kind, entry, context: :current) ⇒ Object?
The stale copy that may be served when a re-fetch fails: the value of the entry captured before the re-fetch, and only when that very entry belongs to the authorization context (checked against the credentials the failed request actually used, when the caller knows them) — an entry installed meanwhile by another request never vouches for it.
683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 |
# File 'lib/mcp_client/result_caching.rb', line 683 def stale_fallback_for(kind, entry, context: :current) return nil unless entry.is_a?(MCPClient::CachedResult) && entry.value # An entry a cleanup or an invalidation replaced while the re-fetch # was in flight is forgotten: only the entry still in the slot serves. return nil unless cache_entries_mutex.synchronize { cache_entries[kind].equal?(entry) } note_served_entry(kind, entry) return nil unless entry_in_current_context?(entry, context: context, kind: kind) # Judging the context runs the probe, and an invalidation may land # while it does: the copy is taken under the lock, and only while the # entry is still the one the map holds. cache_entries_mutex.synchronize do next nil unless cache_entries[kind].equal?(entry) && entry.value MCPClient::DeepCopy.copy(entry.value) end end |
#stale_list_entry(kind) ⇒ MCPClient::CachedResult?
The entry whose (possibly stale) list may be served when a re-fetch fails; the entry itself, so the fallback is judged by the entry that supplied the value and not by whatever entry is installed by then.
536 537 538 |
# File 'lib/mcp_client/result_caching.rb', line 536 def stale_list_entry(kind) cache_entries_mutex.synchronize { cache_entries[kind] } end |
#stale_list_value(kind) ⇒ Object?
The list recorded for a kind whatever its freshness: the candidate for serving stale when a re-fetch fails (#stale_fallback_for decides).
527 528 529 |
# File 'lib/mcp_client/result_caching.rb', line 527 def stale_list_value(kind) cache_entries_mutex.synchronize { cache_entries[kind]&.value } end |
#store_read_entry(key, entry, replacing:, epoch:, now:) ⇒ void
This method returns an undefined value.
Store a read's entry, or drop the slot it replaces. An uncacheable result is not stored, and the slot is dropped only when it still holds the entry the read set out to replace (its own context's, seen when it started): another context's private entry, or one a later fetch installed meanwhile, stays.
940 941 942 943 944 945 946 947 948 949 |
# File 'lib/mcp_client/result_caching.rb', line 940 def store_read_entry(key, entry, replacing:, epoch:, now:) cache_entries_mutex.synchronize do if last_result_from_round_trip? || !entry.hint? || !entry.fresh?(now: now) cache_entries.delete(key) if replacing && cache_entries[key].equal?(replacing) elsif epoch == cache_generation(key) prune_read_entries(now: now) cache_entries[key] = entry end end end |
#take_served_entry(kind) ⇒ Array(Object, String)?
Take the note left for a kind: it is written for the one cache above this transport that tags its slice with it, so reading it consumes it. The slot itself goes once nothing is left in it, rather than staying on the thread for the life of a worker that lists through many transports.
347 348 349 350 351 352 353 354 |
# File 'lib/mcp_client/result_caching.rb', line 347 def take_served_entry(kind) notes = Thread.current[served_entries_key] return nil if notes.nil? note = notes.delete(kind) Thread.current[served_entries_key] = nil if notes.empty? note end |
#transport_thread_local_keys ⇒ Array<Symbol>
The thread-local slots a transport owns, each keyed by its own
object_id: the notes of the entries it served and recorded, the
receipt time, the credentials and effective parameters of the request
this thread last sent through it, and its multi round-trip marker.
The evaluation an open operation reserved for its own request is
deliberately not among them: a reconnect tears the connection down
(ensure_connected cleans up before it connects) in the middle of the
very request a cache decision reserved it for, and that request must
still carry it. The reservation belongs to the operation, which drops
it when it ends (MCPClient::RequestMetaScope).
375 376 377 378 379 380 381 |
# File 'lib/mcp_client/result_caching.rb', line 375 def transport_thread_local_keys i[served_entries_key recorded_entries_key response_received_key request_params_key round_trip_marker_key exchange_records_key called_tool_definition_key pinned_retry_definition_key] .select { |name| respond_to?(name, true) } .map { |name| send(name) } end |