A list endpoint returning everything is a familiar, forgivable shortcut. A human calling it gets a slow page and scrolls. A browser handles a 794KB JSON response without complaint.
An agent does not. The response goes into a context window, and a large one either truncates the conversation, evicts the instructions that came before it, or fails outright. The tool call does not return data, it returns a wall.
We had this on list_inboxes. An account that provisions an inbox per task accumulates them quickly, and one had 3,219. The response was 794,423 characters across 28,975 lines. It exceeded the caller's limit and returned nothing usable, on the MCP surface we describe as the reason to use the product.
The costs are different
For a human API the cost of a big response is latency and bandwidth, both of which degrade gracefully. For an agent the costs are:
- Context. Finite and shared with everything else the agent needs to remember. A large response can evict its own instructions.
- Money. Tokens are billed. A response nobody reads is still paid for.
- Attention. Even within limits, burying three relevant rows in three thousand measurably degrades what the model does next.
Graceful degradation does not exist here. It works, or the turn is destroyed.
What a paginated response owes an agent
Human pagination assumes a UI that renders "next". An agent has to decide, from the response alone, whether to continue. So say so explicitly:
{
"data": [ /* 50 rows */ ],
"cursor": "inb_a1HjMJs44WShWc3CpEeI",
"has_more": true,
"total": 3226
}
total is the field people leave out and it is the one that changes behaviour. Without it, the agent knows there is more but not whether that means one page or sixty five. With it, it can decide to filter instead of paging, or tell the user the list is large, or stop. Return it on the first page only, since recounting on every page is wasted work.
Make the tool description carry it
The schema is not enough. The model reads the description, so the description has to explain the protocol:
"List the email inboxes you have created, newest first. Paginated: the
first page reports 'total', and 'has_more' with 'cursor' means there are
more. Pass that cursor back to continue."
Do not break the SDKs
Adding a default limit changes behaviour for everyone who was relying on getting everything. A method documented as returning all inboxes should keep doing that, which means the SDK follows the cursor internally:
async listInboxes() {
const out = [];
let cursor;
for (;;) {
const res = await this.get("/v1/inboxes?limit=200" + (cursor ? "&cursor=" + cursor : ""));
out.push(...res.data);
if (!res.has_more || !res.cursor) return out;
cursor = res.cursor;
}
}
The agent-facing tool exposes the pages. The library-facing method hides them. Those are different audiences and it is fine for them to differ.
One implementation note
If your ids are random, a cursor of WHERE id < :cursor is not correct, because random ids do not sort in the order you are returning rows. Use your database's cursor primitive, which walks the ordered result, or key the cursor on the actual sort column. This bug is quiet: it works for page one and silently skips or repeats rows later.