If your agent works fine for six turns and then starts forgetting instructions, repeating calls or getting compacted, the usual cause is not the model and not the number of tools. It is one tool that returned 40,000 tokens of JSON. The fix is to treat every tool result as a projection with a hard token budget enforced inside the server: cap it, page it, tell the model what was cut, and return a reference instead of a payload when the payload is large. This is server-side work, because no client can un-send what you already put in the transcript.
Key takeaways
- Tool schemas cost tokens once per request. Tool results cost tokens on every turn for the rest of the session, which makes them the dominant variable cost in a long agent run.
- Instrument result size per tool and track p50, p95 and max. Almost every context blowup traces to a single tool whose p95 is 20x its median.
- MCP cursor pagination applies to list operations (tools/list, resources/list), not to tool call results. Pagination of results is your job, expressed as tool parameters.
- Truncate loudly. A silently cut result makes the model report a partial answer as a complete one.
- Return a resource link or an identifier when the payload is large, and let the model pull only the fields it needs.
- Sensible defaults: a soft budget around 2,000 tokens per call, a hard cap around 10,000, and a page size chosen so the common task finishes in one call.
Why tool results, not tool definitions, fill the window
Tool definitions are a fixed cost and tool results are a compounding one. A 60 tool server might spend 15,000 tokens on schemas, but that block sits at the front of the prompt, is identical on every request, and is exactly the shape that prompt caching handles well. A tool result is different: it is appended to the conversation, and every subsequent model turn re-sends it. One 40,000 token database dump on turn three is carried through turns four, five and six as well. Cached reads are cheaper than fresh input, but the context window does not care whether a token was cached, and neither does attention.
That distinction matters because the two problems have different fixes. Too many tool definitions is a discovery problem, solved by tool search or by exposing tools as code, which I covered in tool search vs code execution. Oversized tool results are a data shaping problem, and no amount of tool filtering touches them.
The damage is not only about hitting the limit. Model accuracy degrades on long inputs well before the ceiling, with information in the middle of a long context recovered less reliably than information at either end (Liu et al., Lost in the Middle). A 40,000 token result does not just cost money, it pushes your system prompt and the user's actual request into the region the model attends to worst. That failure pattern is what I described in context rot.
How to measure what each tool actually returns
Log the serialized size of every tool result, per tool name, and alert on the tail rather than the average. Median size is almost never the problem. The problem is the one call where a customer had 4,000 orders instead of 12, and your list endpoint returned all of them.
Wrap the handler rather than editing each tool. With the official Python SDK, a decorator applied at registration is enough (modelcontextprotocol/python-sdk):
import json, time, logging, functools
log = logging.getLogger("mcp.size")
# Measure this ratio on your own payloads with a tokenizer or the
# provider's token counting endpoint. Dense JSON runs lower than prose.
CHARS_PER_TOKEN = 3.2
def measured(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = await fn(*args, **kwargs)
payload = json.dumps(result, separators=(",", ":"), default=str)
est_tokens = int(len(payload) / CHARS_PER_TOKEN)
log.info(
"tool=%s ms=%d bytes=%d est_tokens=%d",
fn.__name__, int((time.perf_counter() - t0) * 1000),
len(payload), est_tokens,
)
return result
return wrapper
Run that for a week against actual agent traffic, then sort tools by p95 estimated tokens. In every server I have shipped, three or four tools account for nearly all of the context pressure, and they are usually search, list and "get full record" endpoints that pass an upstream API response through unchanged. Those are the only ones worth redesigning. If you already emit OpenTelemetry spans, attach the size as a span attribute so it sits next to latency in the same trace, which pairs naturally with the GenAI semantic conventions.
How to enforce a token budget inside the server
Put the cap in the server, because that is the only place that can shape the data instead of chopping it. Clients do defend themselves: Claude Code caps MCP tool output and exposes a MAX_MCP_OUTPUT_TOKENS setting to change the threshold (Claude Code MCP docs). But a client-side cap is a blunt cut at the character level. It can slice a JSON document in half, leaving the model with syntactically broken output and no idea what it lost.
Server-side budgeting means deciding, per tool, what to drop first. Order of preference, in my experience: drop rows before dropping fields, drop verbose fields (descriptions, HTML bodies, base64) before identifiers, and never drop the fields the model needs to make a follow-up call.
SOFT_BUDGET = 2_000 # tokens; normal responses stay under this
HARD_CAP = 10_000 # tokens; never exceed, regardless of arguments
def fit(rows, budget=SOFT_BUDGET, keep=("id", "name", "status", "updated_at")):
"""Return as many whole rows as fit, projected to `keep` fields."""
out, used = [], 0
for row in rows:
slim = {k: row[k] for k in keep if k in row}
cost = len(json.dumps(slim, separators=(",", ":"), default=str)) / CHARS_PER_TOKEN
if used + cost > budget:
break
out.append(slim)
used += cost
return out, len(rows) - len(out)
The hard cap is not paranoia. It is the difference between a degraded answer and a dead session, and it protects you from arguments you did not anticipate, such as an empty filter or a wildcard the model invented.
Why MCP pagination does not help tool results
The cursor pagination in the MCP specification covers list operations, not tool call results. It is defined for tools/list, resources/list, resources/templates/list and prompts/list, where the server returns an opaque nextCursor the client passes back (MCP pagination spec). There is no protocol-level cursor for the output of tools/call.
So you express pagination as tool parameters and let the model drive it. Two rules make this work. First, the cursor must be a parameter the model can copy verbatim, which means an opaque string, not a page number it might do arithmetic on. Second, the input schema description has to state the default page size and the fact that more pages exist, because the model only knows what the schema and the result tell it.
{
"name": "search_orders",
"description": "Search orders. Returns at most 25 per call; pass the returned cursor to get the next page.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer", "default": 25, "maximum": 100 },
"cursor": { "type": "string", "description": "Opaque cursor from a previous call. Omit for the first page." },
"fields": {
"type": "array",
"items": { "type": "string" },
"description": "Fields to return. Defaults to id, status, total, placed_at. Request more only if needed."
}
},
"required": ["query"]
}
}
A cursor in the tool arguments also has a property the protocol cursor lacks: it survives a stateless server. Nothing about the paging state lives in the connection, which matters if you run behind a load balancer, as I covered in where MCP server state lives without sessions.
When to return a reference instead of the payload
Return a reference whenever the model does not need to read every byte to complete the task. The 2025-06-18 revision of the spec added a resource_link content type for exactly this: a tool result can point at a resource URI the client may read later, instead of inlining the content (MCP tools spec).
{
"content": [
{ "type": "text",
"text": "412 orders matched. Showing 25 (cursor: eyJvIjo0MTJ9). Full result available as a resource." },
{ "type": "resource_link",
"uri": "orders://exports/8f3ad2/rows.csv",
"name": "rows.csv",
"mimeType": "text/csv" }
]
}
The client decides whether to fetch it. That is the point: a 3MB CSV enters the context only if something actually needs it, and if the agent has a code execution environment it can process the file without the bytes passing through the model at all. Anthropic's write-up on code execution with MCP reports large reductions in token use from exactly this move, filtering and aggregating in code and returning only the answer. If you are new to serving resources, the mechanics are in exposing resources from an MCP server.
The same logic applies to identifiers. A search tool that returns 25 IDs plus a one line summary each, paired with a get_order tool, costs a fraction of a search tool that returns 25 full order objects, and the agent fetches detail for the two orders it cares about.
How to make truncation legible to the model
Every truncated result needs a machine readable statement of what was cut, in the same result. A model that receives 25 rows with no indication that 387 more exist will confidently answer "there are 25 matching orders". That is the most damaging failure in this whole area, because it is silent and it looks like a correct answer.
The envelope I use on every list-shaped tool:
{
"returned": 25,
"total_matched": 412,
"truncated": true,
"omitted_fields": ["line_items", "notes"],
"next_cursor": "eyJvIjo0MTJ9",
"hint": "Call search_orders again with cursor to page, or get_order(id) for line_items.",
"rows": [ ... ]
}
The hint field is doing more work than it looks like. Models recover from truncation when the recovery path is stated in the result, and stall or guess when it is not. Naming the exact tool and argument turns a dead end into a next step. Keep the envelope keys short and stable, since they are paid for on every call.
Also decide what your tool does when a result is empty versus when it was filtered to nothing by your budget. Those are different states, and collapsing them into an empty array teaches the agent that its query was wrong when the query was fine.
The structured output trap that doubles your payload
If you adopted outputSchema and structuredContent, check whether you are now sending your result twice. The spec states that for backwards compatibility, a tool returning structured content should also return functionally equivalent unstructured content, typically the serialized JSON in a text block (MCP tools spec). Some SDKs do this automatically. The result is a tool that quietly costs twice what your logs suggest, since size instrumentation placed on your handler's return value never sees the duplicate the transport adds.
Two mitigations. Measure the serialized wire response, not the handler return value, so the duplication shows up in your numbers. Then, for large results, make the text block a summary rather than a second copy: a sentence stating counts and the cursor, with the data living only in structuredContent. You lose nothing with clients that read structured content and you give older clients something usable instead of nothing. Verify what actually goes over the wire with MCP Inspector rather than trusting the SDK's documentation on this point.
Trade-offs and what I would ship
Paging and referencing are not free. Every extra round trip is a full model turn: prefill over the whole growing transcript, a decode, and the network hop to your server. On a large model that is a couple of seconds and a fresh billing event. If the agent's common task genuinely needs 200 rows and your page size is 25, you have traded one 20,000 token result for eight round trips that together cost more in latency and more in cumulative prefill than the dump you were avoiding. Pagination wins when the model needs a few of many, and loses when it needs all of a bounded set.
So size the page to the task, not to a round number. Look at your logs, find how many rows the agent typically needs before it stops calling, and set the default limit just above that. If 90 percent of sessions stop after the first page, the page size is right. If the agent almost always pages twice, it was too small.
Where I would not bother: single-shot pipelines with one tool call and no follow-up turn, results with a fixed small ceiling (a status object, a config record), and anything under a few hundred tokens. Adding cursors there is complexity for nothing, and every parameter you add is a parameter the model can get wrong.
Concretely, on a new server I set a 2,000 token soft budget and a 10,000 token hard cap per call, default field projections that exclude free text and binary blobs, an opaque cursor on every list tool, a truncation envelope on every result that can be cut, and a resource link for anything over the hard cap. Then I check the p95 per tool a week later and adjust the page sizes. That takes an afternoon and it removes the most common cause of an agent that mysteriously degrades halfway through a session. It also pairs well with compaction rather than replacing it, since compaction still has to run eventually, and it runs better on a transcript of summaries than on a transcript of dumps (when to summarize vs truncate).
Sources
- Model Context Protocol specification, Tools (2025-06-18), covering resource links, structured content and the backwards compatibility requirement.
- Model Context Protocol specification, Pagination, which defines cursors for list operations only.
- Anthropic Engineering, Code execution with MCP.
- Anthropic, Claude Code MCP documentation, including MCP output token limits.
- Liu et al., Lost in the Middle: How Language Models Use Long Contexts, arXiv 2307.03172.
- Official MCP Python SDK.