Skip to content

Build1 publisher2 min readPublished

GitHub Copilot CLI fired nine tools/list calls at one MCP server in forty seconds

The 2026-07-28 MCP spec adds ttlMs and cacheScope so clients can stop re-fetching discovery data. The server in this walkthrough caches its own build anyway, keyed by scope, because its author does not trust callers to honour the hint.

The Engineer · Build desk

Illustration accompanying GitHub Copilot CLI fired nine tools/list calls at one MCP server in forty seconds

What happened

  • SEP-2549 in the 2026-07-28 MCP spec adds two fields to cacheable results, ttlMs and cacheScope, with semantics the spec ties to HTTP Cache-Control max-age and public versus private.
  • Pointed at the author's FastAPI server, GitHub Copilot CLI issued nine tools/list calls in about forty seconds in a single short session, along with repeated re-initializes.
  • The spec marks discovery reads cacheable, including server/discover, tools/list, prompts/list, resources/list, resources/templates/list and resources/read, while tools/call is always no-store.
  • The server takes its defaults from environment variables, with MCP_TOOLS_TTL_MS set to five minutes in milliseconds and MCP_TOOLS_CACHE_SCOPE set to private.
  • The server also keeps its own in-process discovery cache, keyed on the scope value, counting hits separately from the backend builds it pays for.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Every planning turn that reaches tools/list also reaches the service registry, the RBAC check and the config behind it, so an agent's loop is a load generator pointed at internal systems.
  • constraint Private scope keys the cache per caller, so a fleet of agent identities buys no sharing at all and the backend cost grows with the number of callers, not the number of requests.
  • decision Anyone shipping the hint has to pick between the top-level field the spec defines and the _meta slot that reads more cleanly, and only the first is where a conforming client looks.
  • capability A server can now bound its own discovery cost without any cooperation from the client, because the field it advertises is also the key it caches on.

A real tools/list is rebuilt on every call. The version in this walkthrough queries a service registry, filters the tool set through RBAC, assembles input schemas from config and applies feature flags. The author counts that as one or more backend round-trips per call [7]. Nine of those in about forty seconds works out to one discovery build roughly every 4.4 seconds [17]. This is planner behaviour: a ReAct-style agent re-checks the available tools during a multi-step task [22]. Under recursive planning loops across many agents, the author calls the discovery endpoint a self-inflicted DoS risk for the databases behind it [8].

The code's default TTL is 5 * 60 * 1000 milliseconds [9], which is 300 seconds [16]. That window is 7.5 times the length of the session that produced the burst, so a client honouring the hint would have fetched once and reused the result eight times [18]. For that saving to transfer, the cached list has to survive whatever the client does between turns. A client that holds the tool list as per-session state pays for a fresh build every time it re-initializes, regardless of the TTL. The same session also produced repeated re-initializes [6].

cacheScope decides who may hold the copy. The spec defines two values. "public" means the result is identical for everyone, so a shared proxy may keep one copy; "private" means it varies per authorization context and must never be shared across callers [3]. A server that filters its tool list through RBAC cannot honestly advertise public, because that list varies by caller [21]. The server's in-process cache uses the same value as its key, "*" for public and "client:{client_id}" for private [12].

The values go out twice. One copy rides inside the JSON-RPC result; the other is a Cache-Control header for gateways that only read headers, with max-age computed as ttl_ms // 1000, or 300 at the default [10][19]. A client implementing CacheableResult reads the top level [4]. The author flags the JSON-RPC half as non-conforming, since the server groups it under _meta.cache while the spec puts ttlMs and cacheScope at the top level of the result [11].

"I can't trust the caller to honor ttlMs", the author wrote [13]. Under the default private scope, the number of expensive builds per five-minute window equals the number of distinct client_ids that call in [23]. The published excerpt stops in the middle of the cache class and does not report what any client did with the emitted fields [20], so the measurement on the record predates them.

What to watch

  • The section of the post that was cut off, which was to cover what each client actually did with the emitted ttlMs.
  • Whether Copilot CLI and other clients keep a cached tool list across re-initializes or drop it with the session.
  • Whether client and proxy implementations read the top-level CacheableResult fields, tolerate _meta.cache, or only parse the Cache-Control header.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories