laravel/mcp: Honor server caching hints on the client
Contributed by pushpak1300
Part of the mcp v1.0.1 release
MCP servers can now tell the Laravel client how long a response is good for. The caching utility in the MCP specification allows a server to annotate responses with a ttlMs time-to-live and a cacheScope of public or private. Pull request #326 teaches the Client to honor those hints: opt in with withCache(), and any response the server marks as cacheable is stored in your Laravel cache and reused until the server's TTL expires. Everything else — including every request made by a client that never calls withCache() — behaves exactly as it did in v0.9.6.
Opt-in, with sensible controls
Caching is disabled by default. Chain withCache() wherever you build the client to switch it on:
use Laravel\Mcp\Client;
$client = Client::web('https://example.com/mcp')
->withCache();
$tools = $client->tools();
$tools = $client->tools(); // Served from cache when the server permits
The method accepts two optional arguments: store, the name of any cache store in config/cache.php (the default store is used when omitted), and for, a caller discriminator that isolates private responses — essential in multi-tenant applications:
$client = Client::web('https://example.com/mcp')
->withCache(
store: 'redis',
for: "tenant:{$tenant->id}",
);
Caching can be turned back off at any time with $client->withoutCache().
Only what the server permits
The new ResponseCache class is deliberately conservative. Once enabled, every dispatch flows through ResponseCache::remember(), but a result is written only when the server says so:
- The method must be one the server declares cacheable (
Server::CACHEABLE_METHODScovers list and read operations such astools/listandresources/read; mutable calls liketools/callare never cached). - The result must be
completewith nonextCursor, so interiminput_requiredresponses and individual pages of a paginated listing are never stored. - The result must carry a positive
ttlMs; a missing or zero value means "do not store this". - TTLs are capped at 86,400,000 ms (24 hours), no matter what the server asks for.
Requests carrying a cursor, inputResponses, or requestState parameter bypass the cache entirely.
Keys that cannot collide
On a miss, the client derives two keys per request: a shared key and a private key. Both hash the transport recipe (driver, URL, command, arguments, headers), the method name, and the sorted request parameters, so a different endpoint, different credentials, or different arguments can never resolve to someone else's entry. This also covers local processes — Client::local('node', ['server.js']) keys on the command and args too.
A response annotated cacheScope: 'public' is written to the shared key; anything else goes to the private key, which additionally folds in your for discriminator. Reads check the private key first and fall back to the shared one: tenant A can never read tenant B's private data, while genuinely public results are shared across all callers for a maximum hit rate. The timeoutSeconds setting is excluded from the hashes, so adjusting timeouts does not flush your cache.
Why this matters
Without client-side caching, every tools() or readResource() call costs a full round trip, even when the server knows the answer will not change for five minutes. Honoring the hints moves that judgment to where it belongs: the server decides what is safe to reuse and for how long, and the client simply obeys. For busy applications — dashboards polling resource contents, agents re-listing tools each session, queues replaying serialized clients — that means fewer requests, lower latency, and less load on the MCP server.
Upgrading from v0.9.6
There is nothing to migrate and no configuration to change:
- Existing code is untouched. Without
withCache(), the protocol skips the cache path entirely — uncacheable methods never even resolve the transport recipe. - Serialization stays compatible.
__serialize()now persists the cache configuration so a client can be queued and restored with caching intact, but__unserialize()reads the newcachekey defensively: payloads serialized by v0.9.6 hydrate exactly as before, with caching off. - Adopting the feature is a one-line change per client. Pass
for:whenever private responses may differ between users or tenants sharing a cache store, and prefer an explicitstore:if you want these entries out of your default store.
Takeaways
Client::withCache(?string $store, ?string $for)enables response caching;withoutCache()disables it again. Off by default.- Only responses the server marks cacheable are stored: a cacheable method, a
completeresult, nonextCursor, and a positivettlMscapped at 24 hours. cacheScope: publicresults share one key across callers; private results are keyed perfordiscriminator and are checked first on reads.- Upgrade impact from v0.9.6 is zero: the feature is opt-in and previously serialized clients remain compatible.
Category: Feature (2)