Skip to content

MCP 2026-07-28 (7/11): cacheable results — ttlMs / cacheScope freshness, invalidation, read caching - #230

Open
simonx1 wants to merge 45 commits into
mcp-2026/subscriptions-listenfrom
mcp-2026/cacheable-results
Open

MCP 2026-07-28 (7/11): cacheable results — ttlMs / cacheScope freshness, invalidation, read caching#230
simonx1 wants to merge 45 commits into
mcp-2026/subscriptions-listenfrom
mcp-2026/cacheable-results

Conversation

@simonx1

@simonx1 simonx1 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

Seventh PR of the MCP 2026-07-28 series (stacked on #229). Implements Caching (SEP-2549, changelog minor #5).

  • MCPClient::CachedResult — parses ttlMs (absent ⇒ no hint; negative/malformed ⇒ 0) and cacheScope (public/private), freshness now < t_received + ttlMs on a monotonic clock, combine for paginated lists (shortest TTL wins, one private page makes the list private).
  • ResultCaching (shared) — hints recorded for server/discover, tools/list, prompts/list, resources/list, resources/templates/list and per-URI resources/read; cache_fresh?, cache_info; invalidation on tools|prompts|resources/list_changed (resources also drops all reads) and resources/updated (that URI); the MRTR rule ("results produced by retrying … MUST NOT be cached") via the resolver's round-trip flag.
  • Transports — HTTP list caches are served only while fresh and re-fetched on access when stale (raw page caches cleared), or served stale with a warning when the re-fetch fails transiently ("MAY serve stale responses if errors occur during re-fetching"); resources/read cached 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").
  • Clientlist_tools/list_prompts/list_resources(cache: true) consult each server's freshness before serving the client-level cache.
  • Legacy servers (no ttlMs) keep the existing cache-until-notification behaviour.

Test plan

  • bundle exec rspec — 2018 examples, 0 failures (20 new in cacheable_results_2026_spec.rb)
  • bundle exec rubocop — clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 7c72f57 to 6b5987f Compare September 1, 2026 20:13
@simonx1
simonx1 force-pushed the mcp-2026/subscriptions-listen branch 2 times, most recently from f317b96 to 875a6c3 Compare September 1, 2026 20:30
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 6b5987f to be066c9 Compare September 1, 2026 20:31
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch 10 times, most recently from a23975f to f7c38b8 Compare September 3, 2026 00:50
@simonx1
simonx1 force-pushed the mcp-2026/subscriptions-listen branch from 4cc387e to e718c14 Compare September 5, 2026 00:34
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 7096ab2 to 6bff7fd Compare September 5, 2026 00:53
@simonx1
simonx1 force-pushed the mcp-2026/subscriptions-listen branch from fdea97c to 320b798 Compare September 5, 2026 13:05
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 7fdaf20 to 3585ed2 Compare September 5, 2026 13:58
@simonx1
simonx1 force-pushed the mcp-2026/subscriptions-listen branch from e645cbe to 332fdc4 Compare September 5, 2026 19:43
simonx1 and others added 9 commits September 5, 2026 21:45
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
simonx1 and others added 28 commits September 5, 2026 21:51
…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
simonx1 force-pushed the mcp-2026/cacheable-results branch from bb8cfed to a6d7d8d Compare September 5, 2026 23:16
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant