toolcap
Keep MCP tool results under the client's token limit, with pages the model can follow. Use it in your own server (one decorator), or as a proxy in front of any MCP server you didn't write.
A tool that returns 200 flow runs, a long log or a big page snapshot can blow past the client's
limit (Claude Code rejects results over 25,000 tokens) or fill the context in one call. toolcap
cuts any result into pages of whole items that fit a token budget, each with a next_cursor
and a note telling the model how to get the next page, in one call or one decorator.
pip install toolcap
Python 3.9+, no dependencies. Works with any MCP framework (the official MCP Python SDK 1.x
FastMCP and 2.x MCPServer, the standalone fastmcp) or none, because it only turns a value
into text.
Fix a server you didn't write
MCP tool "..." response (61,234 tokens) exceeds maximum allowed tokens (25000) in Claude
Code, or a client that silently fills its context with one giant snapshot? Wrap the server in
toolcap proxy in your MCP config. It works with servers in any language:
{
"mcpServers": {
"filesystem": {
"command": "uvx",
"args": ["toolcap", "proxy", "--",
"npx", "-y", "@modelcontextprotocol/server-filesystem", "/home/me/code"]
}
}
}
Or from the command line: claude mcp add filesystem -- uvx toolcap proxy -- npx -y @modelcontextprotocol/server-filesystem ~/code.
Everything after -- is the command you had before. The proxy adds an optional cursor
parameter to every tool. A result that fits is passed through unchanged; one that doesn't is
cut into pages exactly as described below. The proxy keeps the last 16 full results in memory,
so the model's next pages are served from there without running the tool again. Tested on the
reference filesystem server's directory_tree of /usr/share: a 1.1 million-token result
became pages under 20,000 tokens.
| Option | |
|---|---|
--max-tokens N |
budget for one result (default TOOLCAP_MAX_TOKENS, else 20,000) |
--trim |
cut long results with a note instead of paging them; adds no parameter |
--cursor-param NAME |
name of the added parameter (default cursor) |
--quiet |
don't log cut results to stderr |
Tools that already have a cursor parameter are left alone and their results are trimmed
instead. outputSchema is removed from the tool list, because a page can't match it. Images
and other non-text content stay on the first page. No uv? pip install toolcap and use
"command": "toolcap".
In your own server
from mcp.server.fastmcp import FastMCP # SDK 2.x: from mcp.server.mcpserver import MCPServer
from toolcap import Cap
mcp = FastMCP("prefect")
cap = Cap(max_tokens=20_000)
@mcp.tool()
@cap.paginate # adds a `cursor: str = ""` parameter for the model
def get_flow_runs(state: str = "COMPLETED", limit: int = 200) -> list:
"""List flow runs."""
return api.flow_runs(state=state, limit=limit) # any JSON-able value
If the 200 runs fit in 20,000 tokens the model gets them unchanged. If not, it gets as many
whole runs as fit, plus a page object:
{"items": [{"id": "...", "name": "..."}, ...],
"page": {"items": "1-37 of 200", "total": 200, "next_cursor": "MTozNzo1YzBmYTE5Mw",
"note": "Showing items 1-37 of 200. Call this tool again with the same arguments and cursor=\"MTozNzo1YzBmYTE5Mw\" for the next page."}}
The model calls get_flow_runs(state="COMPLETED", limit=200, cursor="MTozNzo1YzBmYTE5Mw") and
gets items 38 onward. The decorator also adds one sentence about next_cursor to the tool's
description. It works on sync and async tools, keeps Context parameters hidden from the model
as before, and resolves string annotations (from __future__ import annotations).
Without the decorator, call it yourself. This is the way to go when you want the cursor in your own signature, or to page only some of the result:
@mcp.tool(structured_output=False)
def read_log(run_id: str, cursor: str = "") -> str:
"""Read a flow run's log."""
return cap.page(api.log_text(run_id), cursor)
What it does with each kind of result
| Result | What the model gets when it is too big |
|---|---|
| A list | A page of whole items, wrapped as {"items": [...], "page": {...}} |
A dict with a list inside ({"count": 500, "results": [...]}) |
The same dict with the largest list paged, the other fields kept, and a page key next to it (_page if the dict already has a page). Pick the list with key="data.items" |
| Text | A chunk cut at a line break (or a space), then [Output cut: showing characters 1-5850 of 65000. Call this tool again ... cursor="..."] |
| One item bigger than the whole budget | That item with its long strings (then long lists) cut, marked ... [N more characters cut], and paging moves on to the next item |
| Anything else (a dict without lists, a huge string field) | Every key kept, long strings and lists cut until it fits |
cap.trim(data) does the same without cursors, for tools where paging makes no sense: the
model gets the first part and a note saying how many items were left out and to narrow the
request. Pydantic models, dataclasses, tuples and sets are converted to JSON first.
Settings
Cap(
max_tokens=None, # default: the TOOLCAP_MAX_TOKENS env var, else 20,000
count=None, # text -> token count; default is a high estimate (UTF-8 bytes / 3)
indent=None, # JSON indent; None (compact) uses the fewest tokens
cursor_param="cursor", # name of the parameter the decorator adds
)
For an exact fit, pass a real tokenizer: Cap(count=lambda s: len(enc.encode(s))). The default
estimate counts one token per 3 bytes of UTF-8, which is above what real tokenizers give for
English, JSON, code and CJK text, so pages come out a little smaller than they could be but
never over the limit. Users of your server can change the budget with TOOLCAP_MAX_TOKENS
without code changes (match it to MAX_MCP_OUTPUT_TOKENS if they raised that in Claude Code).
Module-level shortcuts with default settings: toolcap.page(data, cursor),
toolcap.trim(data) and @toolcap.paginate.
How paging works, and its limits
- Cursors are stateless: an offset plus a short fingerprint of the full result. The server runs the tool again on every page and slices it, so nothing is stored between calls and it works across processes and restarts. The cost is that each page re-runs the query; for an expensive backend, cache the result yourself or pass the cursor's offset on to the backend.
- If the result changed between pages (new rows came in), the page's note says so and that items may be repeated or missed. Give results a stable sort order.
- A cursor that toolcap did not issue is rejected with an error the model can act on ("call the
tool without a cursor to start from the beginning"). It is the SDK's own
ToolError, so the text reaches the model on SDK 2.x too. - The decorated tool returns text and declares no output schema, so the SDKs don't also copy
the page into
structuredContent(which would double its size, and some clients skip their truncation for structured content). Usestructured_output=Falseon hand-written-> strtools for the same reason. - The budget covers one result. It does not know how full the model's context already is.
- Lists are paged at one level (the largest list reached through dict keys). Lists inside list items are cut, not paged.
Why this exists
MCP servers keep running into the client's result-size limit, and each one fixes it by hand
with ad-hoc limit parameters or a second "minimal" tool:
- strands-agents/harness-sdk#5862: "enforce a response size cap with truncation and continuation"
- microsoft/playwright-mcp#395 (14 👍): "the snapshot length is too long, add pagination to it"
- OpsLevel/opslevel-mcp#47 (13 👍): filters to avoid response size errors
- PrefectHQ/prefect-mcp-server#125:
get_flow_runsreturning ~261k tokens - eyaltoledano/claude-task-master#1137:
pagination for
get_tasks - mui/material-ui#46778: MCP tool "returning too many tokens for Claude Code"
License
MIT
Metadata
Release files for toolcap 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| toolcap-0.2.0.tar.gz | 24.5 kB | Without provenance | Details for toolcap-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| toolcap-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for toolcap-0.2.0-py3-none-any.whl |
Total release size: 44.5 kB
Release files / toolcap-0.2.0.tar.gz
| Download URL | toolcap-0.2.0.tar.gz |
|---|---|
| Size | 24.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c7d4ad29712822a756ae3b0357b955829e6a7c0342f4d08b49ed7658f3c59cd4
|
|
BLAKE2b-256 checksum How to use checksums |
11f60fea0396a820e329acce74759fe5f07a9abbfdcbe7420acc972778e9b306
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / toolcap-0.2.0-py3-none-any.whl
| Download URL | toolcap-0.2.0-py3-none-any.whl |
|---|---|
| Size | 20.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d0246838c7d12a174a8c995381773ea63fff10763dbac9a2982ce0e9ef12bb25
|
|
BLAKE2b-256 checksum How to use checksums |
381a78a6cb89b3dbcb08cee7746a4c3df10df7ce4302f42285d1e00047172d4e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Security for toolcap 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.