Going stateless has a hidden running cost. With the session gone, a correct MCP client can't be told when your tool list changed — so a naive one re-requests tools/list every turn and re-injects the entire catalog into the model's context, paying input tokens to re-teach the model a list that never moved. The 2026-07-28 spec gives you two fields to stop that. This is the copy-paste version. (For why it matters, see the companion explainer: the same MCP spec added response caching.)
The whole idea: annotate the result you already return with ttlMs (how long it's fresh) and cacheScope (who can share it). It's Cache-Control for MCP.
1. The shape of a cacheable result#
Caching metadata rides in _meta on a list or resource-read result. At the wire level, a cacheable tools/list response looks like this:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"tools": [
{ "name": "search_docs", "description": "…", "inputSchema": { } }
],
"_meta": {
"cache": {
"ttlMs": 300000,
"cacheScope": "shared"
}
}
}
}
ttlMs: 300000 means "trust this for five minutes." cacheScope: "shared" means "this list is identical for everyone — one cached copy is safe for all users." That second field is the one with teeth; we'll come back to it.
2. Set it on the server#
You're annotating the response your handler already builds. In TypeScript, using the 2026-07-28 SDK beta:
// tools/list handler
server.setListToolsHandler(async () => {
const tools = await loadToolCatalog();
return {
tools,
_meta: {
cache: {
// Catalog only changes on deploy → cache for 5 minutes.
ttlMs: 5 * 60 * 1000,
// No permission filtering here → identical for everyone.
cacheScope: "shared",
},
},
};
});
The Python beta is the same move — attach the metadata to the result:
@server.list_tools()
async def list_tools() -> ListToolsResult:
tools = await load_tool_catalog()
return ListToolsResult(
tools=tools,
_meta={"cache": {"ttlMs": 300_000, "cacheScope": "shared"}},
)
Choosing ttlMs: derive it from how often the surface actually changes, not a gut number. A tool catalog that only changes on deploy can sit at minutes. A resource that mirrors live data wants seconds — or no caching at all (omit the fields and it's treated as uncacheable).
3. The one mistake that turns a cache into a leak#
Now the field with teeth. Suppose your tool list is filtered by permission — an admin sees delete_account, a regular user doesn't:
server.setListToolsHandler(async (ctx) => {
const tools = await loadToolsFor(ctx.user); // ← varies by user!
return {
tools,
_meta: {
cache: {
ttlMs: 60 * 1000,
// ❌ WRONG: shared cache of a per-user list.
// A regular user could be served the admin's cached tools.
// cacheScope: "shared",
// ✅ RIGHT: this response depends on who's asking.
cacheScope: "per-user",
},
},
};
});
Mark a permission-filtered list as shared and a client may serve one user's tool set to another. That's an authorization leak dressed up as a performance win. The rule is the HTTP one this borrows from: if a response depends on who is asking, it's private — here, per-user. When unsure, scope narrow. A cache miss costs tokens; a cache leak costs trust.
4. Honor it on the client#
If you own the client, the cache is a small keyed store with per-entry expiry. The key must include the user identity whenever the scope is per-user, or you reintroduce the exact leak you just avoided on the server:
type Entry = { value: unknown; expiresAt: number };
const store = new Map<string, Entry>();
// Wall-clock ms; inject a clock in tests rather than calling Date.now() directly.
function cacheKey(method: string, params: unknown, meta: CacheMeta, userId: string) {
const scopeSalt = meta.cacheScope === "per-user" ? userId : "shared";
return `${method}:${scopeSalt}:${JSON.stringify(params)}`;
}
async function callCached(method, params, meta, userId, now, fetchFresh) {
const key = cacheKey(method, params, meta, userId);
const hit = store.get(key);
if (hit && hit.expiresAt > now) return hit.value; // fresh → skip the round trip
const value = await fetchFresh(); // stale/absent → re-fetch
if (meta.ttlMs > 0) {
store.set(key, { value, expiresAt: now + meta.ttlMs });
}
return value;
}
Two things make this correct rather than merely fast:
- The key carries the scope. Per-user responses get the user folded into the key; shared responses collapse to one entry. Skip this and a "shared" bug on the client leaks across users even if your server was careful.
- Expiry is honored, not ignored. A client that never checks
expiresAtand just re-fetches every turn does all the work of this migration and keeps the whole token bill. The win only lands if the client actually trusts a fresh entry.
5. Invalidation: mostly, don't#
There's no push channel anymore — that went out with the session — so this cache is TTL-based, not event-based, by design. You don't invalidate; you expire. If a change needs to propagate fast, use a short ttlMs so the staleness window is small. If it needs to propagate instantly, don't cache that response — omit the fields and pay the re-fetch. Decide per response, not globally.
The five-minute checklist#
- [ ] Every
tools/listand resource-read result carriesttlMs. - [ ]
ttlMsreflects how often that surface really changes. - [ ]
cacheScopeisper-userfor anything permission- or tenant-filtered;sharedonly when the response is identical for all. - [ ] Your client keys per-user entries by user identity and honors expiry.
- [ ] Live/volatile responses omit caching rather than caching briefly-wrong data.
Do this and stateless stops being chatty. You get the round-robin-load-balancer deploy story and a tool channel that doesn't re-bill you for a catalog that never changed.



