MCP 2026-07-28 (7/11): cacheable results — ttlMs / cacheScope freshness, invalidation, read caching - #230
Open
simonx1 wants to merge 45 commits into
Open
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 1, 2026 20:13
7c72f57 to
6b5987f
Compare
simonx1
force-pushed
the
mcp-2026/subscriptions-listen
branch
2 times, most recently
from
September 1, 2026 20:30
f317b96 to
875a6c3
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 1, 2026 20:31
6b5987f to
be066c9
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
10 times, most recently
from
September 3, 2026 00:50
a23975f to
f7c38b8
Compare
simonx1
force-pushed
the
mcp-2026/subscriptions-listen
branch
from
September 5, 2026 00:34
4cc387e to
e718c14
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 5, 2026 00:53
7096ab2 to
6bff7fd
Compare
simonx1
force-pushed
the
mcp-2026/subscriptions-listen
branch
from
September 5, 2026 13:05
fdea97c to
320b798
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 5, 2026 13:58
7fdaf20 to
3585ed2
Compare
simonx1
force-pushed
the
mcp-2026/subscriptions-listen
branch
from
September 5, 2026 19:43
e645cbe to
332fdc4
Compare
MCPClient::CachedResult carries a result's freshness hint; the shared ResultCaching module records hints per operation (discover, tools, prompts, resources, templates, per-URI reads), answers freshness checks, invalidates on change notifications, and never caches a multi round-trip retry result. HTTP transports serve cached lists only while fresh, re-fetch on access once stale (shortest page TTL for paginated lists) or serve the stale copy when the re-fetch fails transiently; resources/read is cached per URI while fresh on every transport; the client-level caches consult every server's freshness. Legacy servers keep the cache-until-notification heuristic. cache_info exposes the hints. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…he slices, page expiry - A re-fetch that fails with an authorization error (401/403, insufficient scope) propagates instead of serving the stale list. - The multi round-trip marker that keeps MRTR results out of the read cache is thread-local, so a concurrent request cannot clear it. - Refreshing a stale client-level list replaces that server's cached slice, so removed tools/prompts/resources no longer linger. - An auto-paginated list expires at its earliest page's receivedAt + ttlMs rather than a full TTL after the last page arrived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… SSE freshness - resources/read is cached only on an explicit ttlMs (absent means 0, and legacy servers keep their never-cached reads). - Cached results and hints are forgotten on cleanup / reconnect, and privately scoped entries are dropped when the Authorization the transport sends changes (checked before a private entry is served). - A change notification marks a list stale instead of forgetting it, so a concurrently snapshotted list is not served; resources/list_changed also covers resource templates; lazy cache structures are created under a lock. - HTTP+SSE serves lists only while fresh, drops them on list_changed and caches reads through the shared read cache. - The HTTP caching helpers moved to HttpTransportBase::CacheSupport. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… ttl default, SSE routing - A private entry is bound to the Authorization of the request that produced it and is served, or offered as a stale fallback, only in that context (covers anonymous-then-token and overlapping requests). - On a 2026-07-28 server an absent ttlMs counts as 0 for lists, pages and reads; legacy servers keep the cache-until-notification heuristic. - SSE stream notifications go through the shared routing (subscription bookkeeping and cache invalidation); SSE and plain HTTP cleanup forget their lists, reads and hints. - Caches are invalidated before a subscription listener is notified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ext, stale placeholders - A response whose entry was invalidated while the request was in flight is not written back (cache epoch); read results are returned as a copy. - A private list whose pages were fetched under different credentials is stale; the stale fallback is judged against the credentials the failed re-fetch used, and only when a hint exists. - A stale placeholder keeps the scope and context of the entry it replaces; HTTP+SSE binds private entries to its Authorization header and records the resource templates hint; cleanup clears the cache outside the transport mutex. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…es, copies, fingerprints - A hinted result without an explicit public cacheScope stays within the authorization context that produced it. - cleanup leaves stale tombstones (not unknown entries) so client-level caches re-fetch; resources/list and templates honour the cache epoch. - Cached resource contents are handed out as independent copies. - Request authorization contexts are stored as SHA-256 fingerprints, not as the credentials themselves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…alues, fresh header derivation - A private list fetched under different credentials carries an unmatchable context and is never a fallback. - Cached lists live in the entry that carries their hint (value and authorization context recorded together); invalidating the tool list also invalidates its entry. - x-mcp-header derivation uses a fresh, in-context tool list; cached resource contents copy every mutable field and hash key; the authorization probe starts from the configured headers; HTTP+SSE binds results to the credentials of their own request; cleanup forgets reads instead of tombstoning them forever. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…inding, unrecorded re-fetches - A list hint recorded without its list (conversion still running or failed) never lets a transport serve its previous copy, nor the client its own cache: lists are served only from the entry that carries the hint; the transport's copy stands in only when nothing was recorded at all. - HTTP+SSE notes the Authorization of the JSON-RPC POST that fetches a result, so private entries bind to the credentials of their own request and are served while fresh. - A re-fetch marks its credentials unknown before it starts; a failure before its headers are applied has no private stale fallback. - A resources/read result that is not an object is rejected instead of becoming an empty resource; a read's TTL runs from its receipt, not from the end of its conversion. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ation, middleware authorization - A fetched list attaches only to the entry its own fetch recorded (CachedResult#fetch_token; the thread keeps a bare identity), so an overlapping fetch can never hang one request's list on another's TTL, scope or authorization context; a fetch invalidated in flight fetches again instead of handing back another request's list, and each request returns its own list rather than a re-read of the transport's copy. - The stale copy served on a transient re-fetch failure is the entry captured before the re-fetch, judged by that entry's own context. - An Authorization header added by Faraday middleware is part of the cache context: the header a request went out with is recorded after it was sent, and the freshness probe runs the middleware stack without sending anything. - A null resources/read result on stdio is rejected like on the other transports. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ameters, copied hits, receipt-time TTLs - The client-level tool, prompt and resource caches record the effective parameter fingerprint each server's slice was filled under and are a hit only while the server's next request would carry the same, so a later transport fetch or a callback under other request_meta never serves another tenant's list; hits and inserts are deep copies. - Transports note the moment a response was received before dispatching the notifications it carried, and the paginated, stdio and read cache paths date their entries from that moment, not from the end of the callbacks (or nested requests) that ran meanwhile. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ts own parameters, copied prompts, locked client maps - The client-level tool, prompt and resource caches are tagged with the parameters of the list they hold, read before the fetch, so a transport hit under another tenant cannot mislabel a slice with a leftover fingerprint. - Prompts are cached as copies like tools and resources. - The client maps and their tags live under one lock: a freshness check and the copy it approves are one snapshot, and a list_changed clear waits for it. - An outer request's credentials and parameters are restored even when parsing its response raises, so a failed re-fetch is judged by its own context. - A response queued by the stdio reader or the SSE stream is dated from its arrival, not from when the waiter woke. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…r transport entry, per-key invalidation, capability-aware fingerprints - A client-level cache slice is tied to the very transport entry its list came from (identity and the parameters that entry is bound to), so a transport list refreshed on its own — rotated credentials, a concurrent fetch, a re-fetch after the TTL elapsed — replaces that entry and the slice with it. - Invalidation generations are kept per cache key: a resource updated while tools/list is in flight no longer discards the tools hint. - The client capabilities a request advertises are part of the parameters a cached result is bound to. - A re-fetch that never built its request matches no stale entry, public ones included. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…very list, no hintless transport copies, non-nil slice identity - clear_result_cache installs a stale placeholder for every list kind, recorded or not, so a list stored while the cache was cleared is never served as an unhinted (legacy) list. - A transport keeps a fetched list only when its hint was attached (or the list carried none); a fetch whose entry was cleared or replaced in flight leaves nothing behind, so ttlMs 0 re-fetches on every access. - A client-level slice is identified by the very transport entry it came from, never by "no entry"; a legacy list stays a hit only while the transport still holds no entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…o, bounded generations, identity-aware fingerprints, lock-free freshness - cache_info hands out detached values: the entry's scope is frozen and copied, so a caller cannot move it away from the private sentinel. - Per-URI invalidation generations are bounded (MAX_READ_GENERATIONS); past that every read counts as invalidated, and the map is dropped whenever the whole cache is cleared. - The client identity a request carries is part of the parameters a cached result is bound to, so client_info= / send_client_info= re-fetch. - The client-level freshness check runs outside the cache lock and the copy is served only when nothing changed meanwhile, so a freshness callback that clears the client cache cannot deadlock; a clear that overtakes a verdict turns the hit into a re-list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…, case-insensitive Authorization, arrival timestamps before parsing - Folding the per-URI invalidation generations jumps the shared read generation past every count it absorbs, so no key's generation stands still or goes back across a fold. - The configured Authorization header is found whatever its spelling (string or symbol, any case) on every transport, including the probe and the recording middleware. - A queued stdio line or SSE event is dated from its arrival, before it is decoded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…cks never outlive cleanup - Client#cached_snapshot re-checks, under the cache lock right before copying, that every slice still comes from the transport entry it was recorded against (a placeholder identifies nothing), so a cleanup that raced the freshness verdict cannot serve the old slice; Client#cleanup clears the client caches. - A stale list served on a failed re-fetch must still be the entry in the slot: an entry a cleanup replaced while the re-fetch was in flight is forgotten, and cleanup drops the replaced objects' values. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… the stack MCPClient::DeepCopy walks hashes and arrays with an explicit stack (the same implementation PR 10 carries), so a schema nested deeper than the Ruby stack allows is copied on a client cache hit instead of raising SystemStackError. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ct receipt time, iterative content copies - list_resource_templates attaches its converted result and is served from that entry while fresh, so a positive ttlMs on templates caches. - The SSE direct-JSON response path dates its result from receipt, before parsing. - ResourceContent copies its annotations and _meta iteratively, so a deep document cannot overflow the stack on a read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…, stateful middleware is an unknown context The freshness probe now resolves the Authorization a Faraday request would really carry. Faraday's header table is case-insensitive, so the probe holds the configured headers in one: an OAuth provider's canonical `Authorization` replaces a header configured as a symbol or in another case instead of leaving the older token in the hash for the fingerprint lookup to find first, which could match a private entry cached under a token that has since rotated away. The lookup itself resolves a plain hash the way Faraday would too (the last spelling written wins). Host middleware installed by `faraday_config` was probed through a fresh instance built per probe, while Faraday reuses the instance in its built stack. Middleware that keeps state of its own — a token rotated per request, anything learned from an earlier response — was therefore predicted from a copy that had not seen those requests. The probe now compares the copy's state with the live instance from the connection's built stack and reports the unknown context when they have diverged, so no private entry is served for such a stack. Middleware sharing state the host keeps outside it (a holder both instances point at) stays predictable, and transparent middleware is skipped as before. Also: stdio serves a fresh resource template list from its entry instead of always re-issuing `resources/templates/list`; unlike tools, prompts and resources, template lists have no client-level cache above the transport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ared middleware state is unknown, empty snapshots cache Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… unpredictable, empty legacy snapshots refetch The freshness probe treated a shared Module (or Class) and any frozen non-container as unchangeable, so middleware built around a module vending one-time bearers, or a frozen Data wrapper around a mutable Hash, was still run by the probe — spending credentials no request carried. Shared state now counts as predictable only where it can vend nothing but itself: frozen, of a kind whose whole state is enumerable, and holding only such values in turn. A logger and a lock stay safe. An empty client-level snapshot became a hit for legacy servers too, which record no freshness hint at all: a 2025-11-25 server returning an empty list was cached for the life of the connection instead of being asked again. An empty snapshot is now a hit only where every server's entry carries a hint of its own; a positive ttlMs on a 2026 server still serves. Also from the round's codex review: a host request_meta callable is evaluated once for a cache decision and the request it leads to (never spending a one-time value on a decision that sends nothing, and never sending metadata the decision did not weigh); a cached resources/read is copied out under the cache lock and only while its entry is still the one the cache holds; and a fetch identity is kept on the thread only for the lists that take it back out, so a discovery leaves nothing behind per transport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…e state is unknown, list values taken under the lock The freshness probe treated two middleware ivars that merely compare equal as a safe stand-in. Faraday rebuilds middleware as `klass.new(app, **kwargs)`, so the usual host shape holds a fresh options hash around the very same vendor: the probe ran `on_request` against it and spent a one-time credential the next real request then never presented. Equal containers now stand in for one another only while nothing mutable is reachable through both of them. A copy is also built only when every argument the handler carries can hand its constructor nothing to spend (a constructor that consumes a nonce is never run), and middleware with no request hook is neither built nor compared, since it cannot change what a request carries. The probe now models the request with the metadata held for the decision instead of evaluating the host's `request_meta` again, so a callable is read once for the decision and the request it leads to. A cached list is copied out under the cache lock and only while its entry is still the one the cache holds, the way reads already were: a `list_changed` notification landing during the (probe-sized) lookup window no longer serves the invalidated list. The stale fallback copies under the lock too. Stdio serves a still-fresh hinted `tools` / `prompts` / `resources` list instead of re-listing on every call; a list without a hint is still left to the client's cache, which asks again for an empty one. A re-fetch that replaced the tool definitions (an expired `ttlMs` during a `tools/call`) announces the change, so the client validates a result against the definitions its call was answered under. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ware is probed, stdio templates follow the hint The freshness probe compared instances, so middleware that rotates its bearer from state its class keeps (`self.class.next_nonce`) or from a counter a `define_method` hook closed over looked inert: the probe ran a copy against the shared state and spent nonces no request ever carried. The rule is inverted for those shapes — a host request hook is run only when its class holds nothing of its own (no class-level instance variable, class variable or singleton method, past the default options Faraday memoizes there) and every method it defines was written with `def`. Anything else reports the unknown context, counter untouched; Faraday's own Authorization middleware and hooks reading a frozen holder still predict the next request. Stdio `resources/templates/list` now goes through `hinted_list_value` like tools, prompts and resources: an unhinted 2025-11-25 list (an empty one included) re-issues the RPC, while a positive `ttlMs` is served. Also from the codex round: a client-level cache hit reads the host's `request_meta` once for the whole decision (the parameters check runs first, and the freshness check reuses what it held) instead of spending two trace ids on a decision that sends nothing; the note a transport leaves on the thread for the cache above it is taken rather than left behind, and dropped on cleanup, so a long-lived worker keeps no slot per transport it has ever listed through; and an SSE response is dated from the arrival of the chunk that carried it, so a slow notification callback in the same chunk cannot stretch a `ttlMs` past the server's hint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…xecuted by the freshness probe Four rounds of reflection could not tell a Faraday middleware that vends a one-time credential apart from an inert one: the rotating state can sit in a constant, a global, a thread-local or the binding of a method, none of which building a copy leaves behind. The freshness probe therefore stops running host code altogether. It answers from what the transport itself knows -- the configured headers plus the OAuth provider -- and reports the unknown context (no private entry served) for any faraday_config stack that is not framework middleware it can read off its own configuration: middleware that sets no Authorization header, middleware with no request phase at all, or Faraday's own Authorization middleware installed with literal configuration. The prediction machinery this replaces (probe_stands_in_for?, probe_inert_middleware_class?, the RubyVM::InstructionSequence inspection, the reachable-state walk) is gone. Also from the same round: - `baggage` carries application-defined context (a tenant, a locale), so it is no longer a cache-neutral `_meta` key: a result cached under one baggage is never served to a request carrying another. - A freshness check that matched an entry but found it stale released the request_meta evaluation it was holding, so the request it then made read the host's callable again and spent a rotating trace id or nonce without sending it. The evaluation is now released only where a cached value is really served. - `resources/list_changed` and `cleanup` clear the transport's `resources/templates/list` alongside the other lists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…tion's Authorization, host callbacks are unknown The freshness probe started from the transport's own headers alone, so a `faraday_config` block that set `conn.headers['Authorization']` left it answering "anonymous" for requests that really go out with a bearer. It now starts from the built connection's header table — reading it executes no middleware and runs no host code — with the transport's headers laid over it, exactly as a real request does, and reports the unknown context when that table cannot be read. A middleware class being inert says nothing when the host handed it code to run: a logger formatter's `request` receives the mutable env, a redirect callback receives the new request env, and a configuration block can define a singleton method on either. Any handler installed with a block, or with a proc, method, class or other callable among its arguments, is now an unknown context, and the "no request phase" test additionally requires that Faraday's own constructor build the instance — a middleware with a constructor of its own can install an `on_request` hook that no class-level test would see. A freshness check holds its `request_meta` evaluation for the request the decision leads to. When the authorization probe raised, no request was built and neither release path ran, so a later request on that thread sent the previous tenant's metadata; an aborted lookup now releases what it held. `cleanup` cleared only the served-entry notes, so the per-object authorization, request-parameter, round-trip and recorded-entry slots piled up on a long-lived worker thread. It now drops every slot the transport owns; an anonymous request is noted with its own marker so an emptied slot reads as "nothing recorded", and the metadata held for the next request is deliberately kept (`ensure_connected` cleans up mid-request before it reconnects). Finally, `Faraday::FollowRedirects::Middleware` — which the gem itself depends on — and `Faraday::Response::Json` join the authorization-neutral list, so the ordinary `f.response :follow_redirects` stack with a static bearer keeps its private cache hit. Neither has a request phase, and the only header follow_redirects touches is the Authorization it deletes on a cross-host redirect, which can cost a hit but never leak one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…eps its hands off the metadata a list holds A cache decision evaluates the host's `request_meta` once and holds that evaluation for the request it leads to. That request reconnects on its way out — `ensure_connected` cleans up and connects inside it — and the `server/discover`, `initialize` and `notifications/initialized` of the handshake consumed the held evaluation, so the list itself then went out carrying a different evaluation than the one the cache decision weighed (and a callable vending a one-time value had it spent on the handshake). Those messages now read the host afresh, as any message of their own does, and leave the held evaluation for the request it was held for. `ServerSSE` wrote and read its per-transport authorization thread-local inline instead of through `request_authorization_key`, so `forget_transport_thread_state` did not know about it: a worker thread that created and discarded SSE transports kept one authorization entry per transport for its whole life, and an emptied slot read as an anonymous request rather than as nothing recorded. The slot, its key and its empty-slot semantics now live in one `MCPClient::RequestAuthorization` mixin shared by every HTTP transport, and the round-30 cleanup guarantee is covered on all four transports rather than on Streamable HTTP alone. The `_meta` bookkeeping that grew out of `JsonRpcCommon` moves to a `MCPClient::RequestMetadata` mixin of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…eleased, a call is validated against its own definition Three findings from the round-25 review, re-verified against the current tip. - The client's freshness check reads each server's parameters in turn, and each transport holds that evaluation for the fetch the check is deciding on. When a later server's probe raised (an OAuth refresh failing) no fetch followed for any of them, but every server the loop had already passed kept holding its evaluation: a later request on the same worker thread went out carrying that decision's tenant, baggage or trace id. Round 30 released the evaluation of the server whose own lookup aborted; the loop now releases all of them on an exceptional exit. - A tools/call derives its Mcp-Param-* headers from the transport's tool list, re-fetching a stale one (ttlMs: 0, or a TTL that expires mid-call). The client then re-resolved the tool for post-call validation by listing again, which fetched once more and could answer with a definition newer than the request carried. The transport now keeps the definition its request went out under (MCPClient::CalledToolDefinition) and the client takes it from there instead of triggering another re-fetch. - ServerHTTP and ServerStreamableHTTP cleared their thread-local slots before terminating the session; the DELETE that terminates it runs the authorization recorder, which put the transport's fingerprint straight back on the thread. Both now forget their thread state after termination, in an ensure so an early return or a failure cannot skip it. The tool list an HTTP transport keeps, its generation and the headers derived from it move to MCPClient::HttpTransportBase::ToolListing, keeping the base module under the length cop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ngs to its own request and dies with its operation A cache decision evaluates the host's `request_meta` once and reserves that evaluation for the request it leads to. The rule used to be "the next message spends it, unless it is one of three handshake methods", and three rounds running found another message that was neither: the `subscriptions/listen` a stdio reconnect re-opens, the `notifications/cancelled` sent for a request that timed out, a request a notification listener issues from inside a response's synchronous dispatch. The reservation also outlived its own operation -- a list whose reconnect or initialization raised left it on the thread, so the next unrelated request on that worker carried the aborted decision's tenant, baggage or nonce. The rule is now the other way round, and structural. `MCPClient::RequestMetaScope` is prepended to every transport: each cache-deciding operation reserves the evaluation for the JSON-RPC method of the request it sends, only that request claims it (`request_meta_claim`), everything else reads the host afresh, and the scope drops the reservation from an `ensure` however the operation ends. An operation that begins while another is already talking to the server -- a listener called from a response dispatch -- reserves its own. The handshake allowlist and `without_held_request_meta` are gone: they were the per-path approximations of this rule. The client's `list_tools`, `list_prompts` and `list_resources` loops move into `MCPClient::Client::ListAggregation` and run inside the same scope, opened on every server and closed on every exit, so a fetch that fails during a reconnect leaves nothing behind for the rescue or the caller to carry on with. A `tools/call` now records the definition its request went out under into a slot of its own, handed to the caller waiting for it when the call returns, so a nested call cannot leave its definition where the outer call's validation looks. And a forced refresh (`cache: false`) drops the transport's own entry for the kind first, so a list the server bounded with a positive `ttlMs` is really re-fetched instead of answered from memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…y one operation and spent by its own request
A reserved evaluation of the host's `request_meta` used to be handed out on
two coarse rules, and both let it reach a request it was never made for.
Sharing went by method name: a `Client` listing opens a same-method
reservation on every server up front, so a list a notification listener ran
on a server the loop had not reached adopted that server's reservation and
spent the evaluation the freshness check had already weighed for it. The
nested request carried another request's tenant, baggage or one-time value,
and the client's own fetch for that server was then sent under an evaluation
nothing had weighed. A reservation is now handed to the one operation it was
opened for, by name and once (`offer_request_meta_hold` /
`withdraw_request_meta_hold`), immediately before that operation runs; an
operation that merely uses the same method reserves its own, and the
"entered before dispatch" marker is gone with the rule it served.
Claiming went by method equality: several public entry points open no
reservation of their own — `rpc_request`, `Client#send_rpc`,
`Client#call_tool_as_task`, the HTTP transports' `fetch_prompts_list` and
`fetch_resources_list` — so a raw `rpc_request('tools/list')` issued from a
notification listener inside a reconnect's handshake spent the reservation
the interrupted listing was holding. Every notification listener and
server-request handler now runs behind a boundary
(`outside_request_meta_hold`): whatever host code asks of the transport is an
operation of its own and reads the host afresh, whatever method it names.
`Client#clear_cache` erased only the client's aggregation maps, so a
transport holding a list the server bounded with a positive `ttlMs` answered
the next listing from its own copy without sending anything. It now drops the
transport-level entry for each list kind too, as `cache: false` already did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Subscription listeners are now delivered on the subscription's own dispatcher thread rather than inline on the transport's reader, so this example's read happens after route_notification returns and the assertion saw nil. What it pins is unchanged — when the listener does run, the entry the notification invalidated is already gone and the read goes to the wire — so it now waits for that read instead of assuming it has happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Round 10 of the subscriptions PR moved the host notification callback after the subscription delivery, so that host code cannot hold up a delivery the transport owes a listener. This client's own cache invalidation was riding on that callback, so on a stacked build a listener reacting to tools/list_changed could still read the client-level list the notification had just invalidated — the transport cache was gone, the client one was not. The caching mixin now offers on_cache_invalidation, invoked where it invalidates its own entries — before the delivery — and the client registers its default notification processing there, leaving only the host's listeners on on_notification. Transports without the mixin keep the single callback, so nothing changes for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…est that produced it
Six findings from a verification pass over the cacheable-results work, each
pinned by an example in spec/lib/mcp_client/cacheable_results_2026_verify_spec.rb
that fails when the fix is reverted.
* A private entry is bound to the credentials its request was actually sent
with. The Authorization was recorded before the adapter sent the request and
then read back out of `response.env.request_headers`, a mutable structure the
response phase may rewrite: a host `on_complete` that redacts the header for
logging filed Alice's `resources/read` under the anonymous context, and the
next request carrying no token at all was served her data without a wire
read. What the recorder saw immediately before transmission now binds the
entry; the environment answers only for a connection that recorded nothing.
The outer request's context is restored after the parse by putting that
record back, rather than re-derived from an environment a nested request may
have touched.
* A result is never reused across parameters host middleware may have
rewritten. Unknown request middleware already blocked private reuse through
the authorization check, but a "public" entry bypassed it: middleware writing
a changing locale into `params._meta` had an English read answer a French
one. A stack the transport cannot read now makes the effective parameters
opaque, and an opaque fingerprint matches nothing. What such middleware was
configured with does not matter here, so Faraday's own `:authorization` keeps
caching on however its credential rotates: it changes a header, not the body.
* A cleanup gives the cache a generation no request in flight can match.
Generations were `base + per-key + shared-read`, and a cleanup bumped the
base while clearing the rest, so a key one invalidation had already bumped
went from `0 + 1` to `1 + 0` -- unchanged, and a read the cleanup overtook
installed itself as fresh. The three counts are now compared side by side.
* A `tools/call` host code nests inside a call records into a slot of its own.
Only `call_tool` opened one, so a raw `rpc_request('tools/call', ...)` from a
notification listener overwrote the definition the outer call went out under,
and a host re-resolving the tool validated its result against a definition it
was never answered under. The boundary a transport crosses to reach host code
now opens a definition slot as well as dropping the request_meta reservation.
* A cursor the server rejects (-32602) takes the pages cached under it with it,
so the next list cannot hand the caller the same dead cursor to follow. An
automatically paginated list restarts once from the first page; an explicit
`list_resources(cursor:)` / `list_resource_templates(cursor:)` still raises,
with the stale first page gone.
* An unhinted template list is fetched again. HTTP, Streamable HTTP and SSE
served `resources/templates/list` through `fresh_list_value`, under which a
legacy list with no hint stays fresh for the life of the connection -- a
regression, since template listing fetched every time before results were
cached at all. They now use `hinted_list_value`, as stdio already did.
Also closes two coverage holes the pass named: an in-flight invalidation
against a positive-ttlMs response (both the paginated and single-page record
paths), a host cache-invalidation callback ordering example, and read error
mapping exercised over the wire after a cached copy expired, under both
protocol eras. The round 13 and 21 examples are updated for the generation
identity; the contract each names is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Restacking this branch onto the verification fixes below it left four genuine conflicts of behaviour, each caught by a new example from one of the branches involved: The host's cache-invalidation hook was invoked twice — once by the transport's own invalidation and once by the routing step that owns it. Routing keeps it; the transport no longer calls it. The refresh a transport announces itself lost half its announcement when the tool-list concern moved to its own mixin: it told the host callback but not the invalidation hook, so a client's caches survived a refresh the transport had already made. The legacy SSE path was routed through the shared pipeline, which its own transport does not implement; it announces both hooks explicitly again, in the order routing uses them. The parameter-header methods existed in two mixins after both branches extracted them, with the later include silently winning. The tool-list mixin keeps them; the header mixin keeps only what it alone defines. The x-mcp-header example that pins the answering definition also reacted to the synthetic list_changed a HeaderMismatch refresh announces, so it moved the list on before the retry went out and then asserted the older shape. It now distinguishes the server's notification from the client's own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Verification round 2 on the caching layer. A result's credentials and its receipt time were both taken after `send_http_request` returned — after every Faraday `on_complete` had run. A host response middleware that sends a request of its own on that thread left its Authorization behind, so Alice's private `resources/read` was filed under Bob's context and handed back to him without a wire read; a middleware that merely took its time made the `ttlMs` count from the end of its own work, so an expired result still read as fresh. The innermost middleware on the connection now stamps both facts into the environment of the request it hands to the adapter, and the exchange reads them back from there — an env belongs to one exchange, a thread-local does not. A `-32602` for a cursor dropped the cached pages only on the explicit-page listings. An auto-paginated `tools/list` restarted from the first page but left the previous sequence in the cache, so a restart that then failed transiently served that sequence straight back; stdio never restarted at all, surfacing the rejection as a `ToolCallError` where the HTTP transports recovered. Both now behave alike. Coverage the review found ineffective, each checked against the mutation that used to survive it: modern list/read results carrying the `resultType: "complete"` discriminator (absent from every fixture), a client-cache hit that is really a client-cache hit rather than a transport one, an HTTP+SSE rotation that first establishes the entry it then rotates away from, a 403 that must not serve a stale list, a modern `-32002` after expiry, and a multi round-trip retry resumed by `requestState` alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…h it Restacking the caching layer onto the verification fixes below it left fifteen failing examples in the spec files those branches own. One was a real break; the rest were fixtures whose request counts predate caching. The rename of the nested-call slot to #outside_called_tool_definition reached the Streamable HTTP dispatcher but not the plain HTTP one, which still named the method that no longer exists. Every SSE-framed message plain HTTP had to route -- a notification, a legacy server's ping, a server-initiated request it must refuse -- raised NoMethodError inside the response parse, so the request that carried it failed. It calls the method the mixin actually defines again. The mixin was also included twice, and the dispatcher's comment survived the rename in both its old and new wording; both are tidied. The rest is fixture drift. On a 2026-07-28 server an absent ttlMs means "assume 0", so a list the server does not bound is stale the moment it arrives and every later access re-reads it: a tools/call re-derives its Mcp-Param-* headers from a list it fetches again, and a list_changed notification has no cache left to drop. Seven examples counted requests against a server that sends no bound, and asserted the counts of a transport that kept its lists for the life of the connection. Each of them now bounds the list its subject does not concern -- the recovery composition's tools/list, the HeaderMismatch refresh sequence, the paginated list, the three lists a change notification invalidates, and the list a session-less modern server serves -- so that what the example counts is the recovery, the refresh or the invalidation it names, and not the caching layer underneath it. The counts themselves are unchanged, and still fail if a bounded result stops being served fresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Three ways one caller's private data could be handed to another. A request went on carrying the host's own objects: `request_meta` was merged into `params._meta` shallowly, and the parameters were fingerprinted again once the response had been parsed. A host that rewrote a tenant string or a scope container in place while the request was in flight had Alice's read filed under Bob's tenant, and Bob was handed her contents without a wire read. A built request now carries a copy of the metadata, and the exchange puts back the fingerprint taken when it was built rather than deriving one from a request object anything may have touched since. A stale list may be served when a re-fetch fails, but only to the context the failed request itself carried. Two requests could restate that context on the same thread first: the `notifications/cancelled` a legacy transport sends for a request it abandoned, which goes out after it with whatever the host holds by then, and a request a host `on_complete` nests inside an exchange that then fails before returning. Either left Bob's fingerprint standing when Alice's refresh gave up, and Bob's private list answered her. A cancellation now leaves the abandoned request's record alone, and an exchange holds a record of its own throughout. Installing the Authorization recorder can fail -- a `faraday_config` block that builds the stack itself locks it -- and the failure was swallowed. The transport then read the credentials back out of a response environment the host's middleware had already redacted, and filed Alice's private read under the anonymous context, where the next request carrying no token at all was served it. An entry whose request nothing could record now belongs to no authorization context rather than to the anonymous one. Coverage the reviewers asked for: `list_resources(cursor:)` is never answered from the page cached without one (HTTP and Streamable HTTP); `CachedResult.combine` makes a list private as soon as one of its pages is, on the wire as well as in a unit; a `resources/read` whose re-fetch fails raises rather than serving stale contents; the client cache answers a second prompts, resources and empty listing without reaching the transport at all; plain HTTP really serves the templates it bounded; a cached binary blob is copied out; and an expired ttlMs fetches nothing until the next access. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The branches below now capture a response's body as it streams, so a break after the final event can settle its request, and they bound discovery by a deadline; this branch probes the Faraday stack to decide what a request would carry. The capture is the transport's own handler, so both probes treat it as neutral, and it is installed best-effort like the recorder: a host that locked its stack gets neither. A discovery deadline is wall time and is taken from the real monotonic clock, not from the cache's clock, which a host may stub or advance for TTL arithmetic. The one such clock is ResultCaching's, public as its doc promises; the private copy that shadowed it goes. The exchange threads the deadline through; the host-code boundary is outside_called_tool_definition, and the stale copy of the client helper that shadowed ListAggregation's is gone. The temporary that held a fetched page must not be called `page`: the cursor restart reads its position back through that name. Fixtures that count requests bound the lists their subject does not concern, since an unbounded list on a 2026 server is re-read before every call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 5, 2026 23:16
bb8cfed to
a6d7d8d
Compare
…d receipts conservatively Review round 4 (codex + grok) on the cacheable-results PR. A re-fetch that failed after a host response phase had rewritten the request headers was judged by the error's copy of those headers: a stack with a redacting on_complete before raise_error made every authenticated failure look anonymous, so the anonymous context's private stale copy answered whoever held the credentials now, while the caller's own stale copy was refused. With the recorder on the connection the exchange's own record now stands on the failure path, over anything a nested request left on the thread; the error's headers are read only on a connection without the recorder. A connection whose middleware stack was locked before the receipt recorder could be installed dated a public result from after the host's response phase, extending its freshness by however long that phase took. The TTL now runs from the moment the request was sent, the latest moment known not to be after receipt. Coverage (round 37): both defects on Streamable and plain HTTP with their positive controls; the cached-read error mapping (-32602 modern, -32002 legacy, plain -32602 legacy) on plain HTTP and stdio; a result that really arrives on the SSE stream dated from its event; cursor as part of the cache key on stdio; a negative ttlMs over the wire; field-level isolation of the copies a cached read hands out; an MRTR-retried read racing a plain read on stdio. The round 11/12/27 probe examples are named for what they pin, the round 12 nested-request example runs under a configuration that demonstrably permits a same-context hit, the probe's request modelling is pinned directly, and client_caching_spec pins the copies its comment names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Seventh PR of the MCP 2026-07-28 series (stacked on #229). Implements Caching (SEP-2549, changelog minor #5).
MCPClient::CachedResult— parsesttlMs(absent ⇒ no hint; negative/malformed ⇒ 0) andcacheScope(public/private), freshnessnow < t_received + ttlMson a monotonic clock,combinefor paginated lists (shortest TTL wins, one private page makes the list private).ResultCaching(shared) — hints recorded forserver/discover,tools/list,prompts/list,resources/list,resources/templates/listand per-URIresources/read;cache_fresh?,cache_info; invalidation ontools|prompts|resources/list_changed(resources also drops all reads) andresources/updated(that URI); the MRTR rule ("results produced by retrying … MUST NOT be cached") via the resolver's round-trip flag.resources/readcached per URI on every transport; stdio records hints so the client layer can react. No background polling ("SHOULD NOT treat TTL as a polling interval").list_tools/list_prompts/list_resources(cache: true)consult each server's freshness before serving the client-level cache.ttlMs) keep the existing cache-until-notification behaviour.Test plan
bundle exec rspec— 2018 examples, 0 failures (20 new incacheable_results_2026_spec.rb)bundle exec rubocop— clean🤖 Generated with Claude Code
https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7