DEV Community

minia2a
minia2a

Posted on Originally published at minia2a.uk

One tools/list call cost 529 KB

November 2026

MCP clients do not ship a catalogue to the model once and cache it. They ask the server for tools/list, and whatever comes back is what the model gets to see — every session, as part of its context. For a server with a dozen tools that is a few kilobytes and nobody thinks about it. For a server that maps a marketplace — one tool per service — it is a different object entirely.

On 11 November 2026 we measured ours, on the live endpoint, with the request a stock client sends:

POST /mcp  {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}

→ 200, 529,287 bytes, 1,859 tools, no nextCursor
Enter fullscreen mode Exit fullscreen mode

529 KB, at roughly 4 bytes per token, is about 132,000 tokens — before the system prompt, before the user's question, before a single tool is called. That is larger than the context window of a good number of the models these clients are wired to. On 22 October the same call was 439,656 bytes over 1,711 tools. It grew 90 KB in 20 days, because the catalogue grew by 148 services.

What happens to a client when the list is that big

Nothing crashes. That is the problem. A client with a smaller budget truncates the list, or leaves it out, or the model sees the first few hundred tools and treats the rest as absent. The failure mode is not an error message — it is an agent that connected successfully, saw a partial catalogue, and called something else. From the server side that looks like "nobody uses this", and it is indistinguishable from having no demand.

The same thing happens at a much smaller scale whenever a tool's inputSchema is verbose. We once shipped an object-level description on every tool; it added 212 KB to the payload — one sentence repeated 1,610 times. It was reverted the same day. Tool-list cost is the sum of per-tool costs, and per-tool costs are usually decided by whoever wrote the last schema.

Paging already existed. The default did not.

Our tools/list has accepted limit, cursor, category and query since 26 October, with the cursor bound to the filters that produced it (reusing a cursor after changing the filter is refused, not silently applied to another result set). Measured the same day:

params bytes tools returned ≈ tokens
{} 529,287 1,859 ≈ 132,000
{"limit":200} 53,008 202 ≈ 13,000
{"limit":100} 27,977 102 ≈ 7,000
{"limit":50} 14,427 52 ≈ 3,600
{"limit":20} 6,306 22 ≈ 1,600

So the fix looked like it already existed. Except that the feature was strictly opt-in: a request without params returned the whole list, byte for byte as before, because that was the compatibility promise made when paging landed. The real decision — what does a client get when it asks for nothing? — was deferred, and "deferred" meant "the 529 KB answer".

MCP hosts do not let a user say "please pass limit: 100". The client builds that request. A server that pages only on request is a server that pages only for callers who already knew to ask.

The decision: no limit means one page

We are changing the default so that no limit — including no params at all — returns the first page of 100 service tools plus nextCursor, and the rest come from following the cursor. Explicit limit is unchanged, up to the existing 500 ceiling. Three properties keep this from being a downgrade:

  • The first page is the most-used 100, not the alphabetical 100. The query has ordered by trial_count DESC, id since an earlier fix, so the ordering is already a popularity ranking.
  • Unlisted services stay callable. tools/call resolves a name against the active catalogue; it never checked whether that name appeared in the list you were served. A model can search, get an id from service_discovery (still the first tool in the list), and call it.
  • The tail is reachable two ways — the cursor, and the search tool that was always there.

Why 100 and not 20 or 200

We measured how concentrated usage actually is, across the 1,675 services that have ever been called (50,393 usage rows):

first page usage rows covered share
50 tools 42,580 84.5%
100 tools 45,060 89.4%
200 tools 46,894 93.1%

Going from 50 to 100 buys 4.9 points for about 14 KB; going from 100 to 200 buys 3.7 points for another 28 KB. 100 is where the curve bends. The row counts are not independent callers — bots and our own probes inflate them — but the decision needs the relative shape, and the shape is stable.

The honest cost of this change: a client that never follows nextCursor will now see 100 tools instead of 1,859. That is deliberate. Today that same client sees a 529 KB list that, in practice, it truncates anyway — except now the truncation is at a boundary we chose and the client can see, with a cursor that says there is more. We would rather be the server that says "here is page one" than the server that hands over a payload larger than the context and lets the client decide what to drop.

What we would tell another MCP server author

  • Measure your tools/list bytes, not your tool count. Bytes per tool is the number that matters, and it drifts upward with every schema edit. Ours is ~285 B/tool today.
  • Treat the no-params case as the real default. Most clients will never pass an option you added for large catalogues. If the safe behaviour only happens on request, the unsafe behaviour is your default.
  • Order the first page by use, not by id. A bounded page is only acceptable if the first page is the one people wanted.
  • Keep a search tool on page one. It is the escape hatch that makes a small default honest instead of lossy.
  • Say what your cap costs. "We return 100 of 1,859" is a fact a caller can work with. "We return the catalogue" was not true in practice, because the caller could not fit it.

The change is written, reviewed against the deployed source, and covered by a test that seeds more services than the default page holds — so a future edit that raises the default, or removes it, fails instead of silently restoring the 529 KB answer. It is queued behind the gateway binary deploy; this post will be updated with the live before/after when it ships.


Originally published at minia2a.uk.

Top comments (1)

Collapse
 
lycore profile image
Lycore Development •

The cursor-bound filters and default page size address the wire payload, but a host that follows every cursor and sends the combined schemas to the model may still pay the full context cost. It would be useful to test representative hosts end to end and compare both total pages fetched and actual tokens presented to the model, including whether discovery works for a tail service.