Cache
NAME
MCP::Client::Cache - the ttlMs/cacheScope cache for modern-era results
DESCRIPTION
The 2026-07-28 protocol has no session, so a client re-asks for the catalog
every time it needs it ā which for an agent loop is constantly. In exchange the
server tells the client how long an answer stays good: a cacheable result
carries ttlMs (how many milliseconds it may be reused for) and
cacheScope (public or private). This class is the client side of that
bargain.
Only four results are cacheable at all: tools/list, prompts/list,
resources/list and resources/read. Three rules keep it honest, and each
one is enforced here rather than left to the caller:
No
ttlMs, no storage. A result that does not say it is cacheable is not cacheable.ttlMsof0means exactly that, and is what a server says about a resource whose contents it cannot vouch for.Legacy results are never stored. The 2025-11-25 era has no cache metadata at all, so there is no TTL to honour and nothing legitimises reuse;
putrefuses them outright.Expiry is by the clock, not by hope. An entry is dropped the first time it is asked for after its TTL runs out.
cacheScope is recorded and readable through scope-of, but does not change
whether an entry is stored: this cache lives inside one client, for one user,
and never hands anything to a third party, so private results are as
cacheable here as public ones. A shared or proxying front-end would have to
look at the scope; that is the point of recording it.
Time is injectable
&.now makes expiry testable without sleeping. Pass a closure over a variable
you increment by hand and a test for "this entry expires after thirty seconds"
runs instantly and deterministically.
EXAMPLES
The shape a client uses it in:
use MCP::Client::Cache;
my $cache = MCP::Client::Cache.new;
method list-tools(Bool :$refresh) {
my $key = $cache.key-for('tools/list');
my $hit = $cache.get($key, :$refresh);
return $hit<tools>.List if $hit.defined;
my %result = self!request('tools/list');
$cache.put($key, %result, era => self.era); # a no-op on a legacy server
%result<tools>.List;
}
Keys are built from the method and its parameters, so two reads of different resources cannot collide and parameter order cannot matter:
my $a = $cache.key-for('resources/read', { uri => 'file:///a', extra => 1 });
my $b = $cache.key-for('resources/read', { extra => 1, uri => 'file:///a' });
say $a eq $b; # True
Expiry under a virtual clock:
my $clock = 0;
my $cache = MCP::Client::Cache.new(now => { $clock });
$cache.put('tools/list', { tools => [], ttlMs => 30_000, cacheScope => 'public' });
say $cache.get('tools/list').defined; # True
say $cache.scope-of('tools/list'); # public
$clock = 29;
say $cache.get('tools/list').defined; # True
$clock = 31;
say $cache.get('tools/list').defined; # False ā and the entry is gone
say $cache.elems; # 0