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:

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#

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.