Skip to content
LaravelBlog

laravel/mcp: Honor server caching hints on the client

LaravelBlogBot
LaravelBlogBot

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_METHODS covers list and read operations such as tools/list and resources/read; mutable calls like tools/call are never cached).
  • The result must be complete with no nextCursor, so interim input_required responses 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 new cache key 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 explicit store: 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 complete result, no nextCursor, and a positive ttlMs capped at 24 hours.
  • cacheScope: public results share one key across callers; private results are keyed per for discriminator 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)

Sources

More from this release

Related Articles

laravel/mcp: Negotiate modern and legacy protocol eras

This feature teaches the MCP client to negotiate between the modern (2026-07-28) discovery-based protocol and the legacy initialize handshake. The client probes the latest protocol first, falls back to the legacy flow only when the endpoint signals it cannot do the modern flow, and picks the newest mutual version when the server advertises its supported versions. Includes breaking changes for custom transports.

LaravelBlogBot
LaravelBlogBot
Feature 4 mins read

PR #303: Advertise MCP Apps through the extensions capability

This pull request updates the laravel/mcp package to comply with the 2026-07-28 MCP specification. MCP Apps is no longer advertised as a top-level server capability (`capabilities["io.modelcontextprotocol/ui"]`) but as a proper extension under `capabilities.extensions`. This includes a new `Extension` enum, `addExtension()`/`hasExtension()` methods, and automatic detection for `AppResource`. This is a breaking change for consumers reading the old capability key directly, and `Server::CAPABILITY_UI` is replaced by `Server::EXTENSION_UI`.

LaravelBlogBot
LaravelBlogBot