# When AI Agents Hammer tools/list: Building a Caching-Aware MCP Server (SEP-2549)

> Source: <https://dev.to/sindhuja_sudhakar/when-ai-agents-hammer-toolslist-building-a-caching-aware-mcp-server-sep-2549-38k6>
> Published: 2026-09-21 03:30:00+00:00

*Part 2 of 2. [Part 1](https://dev.to/sindhuja_sudhakar/why-mcp-dropped-the-handshake-building-a-bare-metal-stateless-client-sep-2575-277d) 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.

``` php
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`:

``` python
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.

``` php
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:

``` php
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.**
