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. ttlMs of 0 means 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; put refuses 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

MCP::Client v0.5.0

talk to an MCP server, in either protocol era

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

MCP::Server:ver<0.6.0+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Cro::HTTP:ver<0.8.11+>:auth<zef:cro>:api<0>MIME::Base64:ver<1.2.5+>:auth<zef:raku-community-modules>

Test Dependencies

Provides

  • MCP::Client
  • MCP::Client::Cache
  • MCP::Client::Correlator
  • MCP::Client::Exceptions
  • MCP::Client::Leases
  • MCP::Client::Leases::Table
  • MCP::Client::Policy
  • MCP::Client::Policy::Commands
  • MCP::Client::Policy::Floor
  • MCP::Client::Policy::Grants
  • MCP::Client::Policy::Rules
  • MCP::Client::Protocol
  • MCP::Client::Reasons
  • MCP::Client::Registry
  • MCP::Client::SSE
  • MCP::Client::Transport
  • MCP::Client::Transport::HTTP
  • MCP::Client::Transport::Stdio
  • MCP::Client::UnknownKeys

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.