Part 2 of 2. Part 1 covered statelessness; here I tackle caching.
Part 1 was about statelessness, now shipped in the 2026-07-28 spec. This time it's another feature from that same spec: caching (SEP-2549, "TTL for List Results"). It adds two tiny fields β ttlMs and cacheScope β to cacheable results, giving clients a way to avoid re-fetching the same discovery data. So I built a server that emits them, pointed real AI clients at it, and watched what actually happened. The result surprised me.
AI agents plan in loops. A human calls a tool because they decided to; an agent can call β and re-discover β the same tools dozens of times inside a single planning loop. That changes the economics of the protocol: one user request fans out into many MCP calls. A ReAct-style agent can repeatedly re-check the available tools during a multi-step task, calling my discovery endpoint (tools/list) over and over.
In a toy server that's harmless β the tool list is a constant. In a real one it isn't. Building a genuine tools/list means querying a service registry, filtering tools through RBAC, assembling input schemas from config, applying feature flags β one or more backend round-trips per call. Now multiply that by a recursive planning loop across many agents, and my discovery endpoint has quietly become a self-inflicted DoS risk for my own databases.
flowchart LR
A["AI agent<br/>(recursive planner)"] -->|tools/list x9| S[MCP Server]
S -->|registry + RBAC + schema| DB[(Corporate DB)]
style DB fill:#f8d7da,stroke:#842029
This isn't hypothetical. When I pointed GitHub Copilot CLI at my server, it fired nine tools/list calls in ~40 seconds in one short session β plus repeated re-initializes. That's the storm SEP-2549 is meant to tame.
ttlMs and cacheScope
SEP-2549 lets the server advertise, right in the discovery result, how long the schema may be cached and by whom. The fields sit at the top level of the result (a CacheableResult):
// a tools/list result
{ "tools": [ /* ... */ ], "ttlMs": 300000, "cacheScope": "private" }
Cache-Control: max-age. Tool schemas rarely change, so a 5-minute window collapses a burst of planning turns into a single fetch."public"``"private"`` Cache-Control: public vs private.
And a rule worth underlining: the spec marks read/discovery results cacheable β server/discover, tools/list, prompts/list, resources/list, resources/templates/list, even resources/read β but not actions. tools/call is always no-store. You cache "what can I do," never "do it."
β οΈ Schema note: In my server I group the hint under _meta.cache for readability, but the spec puts ttlMs and cacheScope at the top level of the result (the CacheableResult shape shown above), not under _meta. The values and semantics are what matter for this walkthrough; check the caching spec for the exact field placement.
My FastAPI server exposes three read-only repo tools (get_git_diff,
inspect_file_structure, fetch_logs) and publishes the hint in two places from one source of truth, so both smart clients and dumb proxies can act on it:
TOOLS_TTL_MS = int(os.getenv("MCP_TOOLS_TTL_MS", str(5 * 60 * 1000)))
TOOLS_CACHE_SCOPE = os.getenv("MCP_TOOLS_CACHE_SCOPE", "private")
def cache_meta():
return {"ttlMs": TOOLS_TTL_MS, "cacheScope": TOOLS_CACHE_SCOPE}
def cache_control_header(ttl_ms=TOOLS_TTL_MS, scope=TOOLS_CACHE_SCOPE):
visibility = "public" if scope == "public" else "private"
return f"{visibility}, max-age={ttl_ms // 1000}"
_meta.cache rides inside the JSON-RPC result β for MCP-aware clients/proxies.Cache-Control maps the same values to an HTTP header β for gateways that only
read headers.
But here's the layer that actually saved my database. Because I can't trust the caller to honor ttlMs (more on that in a second), the server caches its own expensive discovery build, keyed by cacheScope:
class DiscoveryCache:
def get(self, client_id, build):
key = "*" if self.scope == "public" else f"client:{client_id}"
now = time.monotonic()
with self._lock:
entry = self._entries.get(key)
if entry and now < entry[0]:
self.cache_hits += 1
return entry[1], True # served from memory, no DB hit
self.backend_builds += 1 # pay the backend cost ONCE
value = build()
self._entries[key] = (now + self.ttl_s, value)
return value, False
Wired into tools/list, the expensive build_tool_list() (my stand-in for the registry + RBAC + schema queries) now runs at most once per TTL window, no matter how often the client re-discovers.
Worth being precise: SEP-2549 gives the consumer a caching hint; this DiscoveryCache is a separate server-side defense I control regardless of whether any consumer honors that hint. They share the same ttlMs value but do different jobs.
Two caveats I'd flag in a real build. First, I key the private scope by client_id, but the spec's private semantics are per authorization context β so client_id is really a stand-in for "the auth principal and scopes that decide who may share a response." Second, keeping the cache in-process (no shared Redis) preserves Part 1's statelessness, but it doesn't give me globally shared cache state β it trades cross-instance sharing for duplication: a burst spread across four replicas can trigger up to four backend builds instead of one. No Redis lock β but not free either.
flowchart LR
A["AI agent<br/>tools/list x9"] --> S[MCP Server]
S -->|1st call only| DB[(Corporate DB)]
S -.->|calls 2-9: served from memory| A
style DB fill:#f8d7da,stroke:#842029
Hypothesis: if the server refuses to rebuild the tool list more than once per TTL window, an agent's repeated tools/list calls should collapse to a single backend hit β regardless of whether the client caches anything.
This is where it got interesting.
Finding 1 β the server-side cache works. Replaying the nine-call storm collapses it to a single backend build:
Replaying a 9-call discovery storm from one client...
tools/list #1: servedFromServerCache=False
tools/list #2..9: servedFromServerCache=True
discovery cache stats: {'backendBuilds': 1, 'cacheHits': 8}
=> 9 client discovery requests collapsed to 1 backend build(s) (88% avoided)
I confirmed it end-to-end with real Copilot CLI: it re-requested tools/list repeatedly, yet my server log showed fromCache: true and backendBuilds pinned at 1 (8 of 9 builds avoided, 88.9%). The DB was queried once and shielded after that.
Finding 2 β the clients I tested didn't honor ttlMs. My server advertised ttlMs: 300000, but both Copilot CLI and Cursor re-requested tools/list repeatedly and didn't cache it client-side. (Their handshakes differ β Copilot CLI is dual-era, probing server/discover before falling back to initialize; Cursor took the legacy initialize path β but neither honored the cache hint in my runs.) I'll be careful with this one: it doesn't mean MCP clients ignore ttlMs in general β SEP-2549 is final in the 2026-07-28 spec, and client support is still landing. The two I tested just hadn't wired it up yet, which is exactly why the server can't rely on the hint being honored.
Finding 3 β client-side caching is timing-sensitive. I wrote a simulated edge proxy that does honor ttlMs; normally it collapses 20 planning turns to ~2 fetches. But in a deliberately adversarial timing setup β a slower laptop where each round-trip took ~2 seconds, matching the proxy's own 2-second window β the client cache expired just before every next call, giving 0% avoided. The same run showed the server-side cache still serving 19 of 20 calls from memory. TTL effectiveness depends on the relationship between cache TTL, request frequency, and round-trip latency β so client-side caching is dependent on consumer behavior and timing, while server-side caching stays effective even when the client ignores the hint.
Worth stating plainly: this protects the server's backend, not the client's. The agent is the source of the load; the corporate database behind my server is the victim. And there are three places a cache can step in β each reduces the load reaching the DB, and the further upstream it lives, the more work it eliminates:
flowchart LR
A[Agent] -->|1. client cache| P[Edge Proxy]
P -->|2. Cache-Control| S[MCP Server]
S -->|3. server-side cache| DB[(Corporate DB)]
style DB fill:#f8d7da,stroke:#842029
| Layer | Mechanism | Protects |
|---|---|---|
| Client | honors ttlMs /cacheScope |
DB + network + server CPU |
| Gateway / proxy | Cache-Control / protocol-aware caching |
DB + server CPU |
| Server | local discovery cache | DB |
Since I can't count on the clients I tested to honor the hint, the server-side cache is the only layer I fully control β and therefore the only protection I can enforce unilaterally today. So my server does both jobs at once: it advertises ttlMs / cacheScope for the ecosystem that's catching up, and it enforces the same ttlMs
internally for right now.
π‘ Key takeaway: A cache hint is only advice β it protects nothing until a consumer acts on it. Advertise ttlMs / cacheScope for the ecosystem you wish you had, and enforce the same TTL inside the server for the ecosystem you actually have.
I assumed the hard part would be designing the cache hint. It wasn't β that's two fields. The surprise was that advertising it changed nothing in the clients I tested: both kept requesting tools/list despite the ttlMs I returned. The protection had to come from the one place I fully control β the server enforcing its own advertised TTL. The lesson mirrors Part 1: a protocol capability can ship before every client has adopted it, so a server that wants the benefit today has to implement both sides of the contract itself.
tools/list into a self-inflicted DoS β every planning turn re-runs the registry/RBAC/schema queries behind discovery.public vs Tying both parts together:
Statelessness removes protocol-level affinity without any client cooperation. Caching has two sides β the server can advertise a policy and defend its own backend alone, but the biggest savings only arrive when clients and proxies honor the hint too.