toolcap 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ node_modules/
11
+ coverage/
12
+ .DS_Store
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-11)
4
+
5
+ First release.
6
+
7
+ - `Cap(max_tokens).page(data, cursor)`: returns any tool result as text that fits a token
8
+ budget. Lists (or the largest list inside a dict, or the one at `key=`) are split into pages
9
+ of whole items with a `page` object (`items`, `total`, `next_cursor`, `note`); text is split
10
+ at line breaks with a footer; one item bigger than the budget is cut and paging moves on;
11
+ values without lists keep every key and get long strings and lists cut.
12
+ - Stateless cursors (offset + fingerprint of the full result). Results that changed between
13
+ pages are flagged in the note; cursors toolcap did not issue raise `CursorError`, an MCP SDK
14
+ `ToolError` whose text reaches the model.
15
+ - `Cap.trim(data)`: the same fitting without cursors, with a note on what was left out.
16
+ - `@cap.paginate` decorator for sync and async tools: adds a `cursor` parameter (resolving
17
+ string annotations, keeping `Context` parameters), mentions it in the description, and
18
+ declares no output schema so pages are not duplicated as `structuredContent`.
19
+ - `TOOLCAP_MAX_TOKENS` environment variable, custom token counters, JSON indent setting.
20
+ - Tested with the MCP Python SDK 1.10, 1.x and 2.x.
toolcap-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sri Krishna Lingamaneni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
toolcap-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: toolcap
3
+ Version: 0.1.0
4
+ Summary: Keep MCP tool results under the client's token limit (Claude Code's 25,000): cursor pagination of long lists and text, or safe truncation, in one call or decorator
5
+ Project-URL: Homepage, https://github.com/srikrishnaL/kitforge/tree/main/packages/toolcap
6
+ Project-URL: Source, https://github.com/srikrishnaL/kitforge
7
+ Project-URL: Issues, https://github.com/srikrishnaL/kitforge/issues
8
+ Project-URL: Changelog, https://github.com/srikrishnaL/kitforge/blob/main/packages/toolcap/CHANGELOG.md
9
+ Author: Sri Krishna Lingamaneni
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,context window,cursor,fastmcp,llm,max_mcp_output_tokens,mcp,mcp server,model context protocol,pagination,response size,token limit,tool result,truncate
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: test
22
+ Requires-Dist: anyio>=4; extra == 'test'
23
+ Requires-Dist: mcp>=1.10; (python_version >= '3.10') and extra == 'test'
24
+ Requires-Dist: pydantic>=2; extra == 'test'
25
+ Requires-Dist: pytest>=7; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # toolcap
29
+
30
+ Keep MCP tool results under the client's token limit, with pages the model can follow.
31
+
32
+ A tool that returns 200 flow runs, a long log or a big page snapshot can blow past the client's
33
+ limit (Claude Code rejects results over 25,000 tokens) or fill the context in one call. toolcap
34
+ cuts any result into pages of whole items that fit a token budget, each with a `next_cursor`
35
+ and a note telling the model how to get the next page, in one call or one decorator.
36
+
37
+ ```bash
38
+ pip install toolcap
39
+ ```
40
+
41
+ Python 3.9+, no dependencies. Works with any MCP framework (the official MCP Python SDK 1.x
42
+ `FastMCP` and 2.x `MCPServer`, the standalone `fastmcp`) or none, because it only turns a value
43
+ into text.
44
+
45
+ ## Example
46
+
47
+ ```python
48
+ from mcp.server.fastmcp import FastMCP # SDK 2.x: from mcp.server.mcpserver import MCPServer
49
+ from toolcap import Cap
50
+
51
+ mcp = FastMCP("prefect")
52
+ cap = Cap(max_tokens=20_000)
53
+
54
+ @mcp.tool()
55
+ @cap.paginate # adds a `cursor: str = ""` parameter for the model
56
+ def get_flow_runs(state: str = "COMPLETED", limit: int = 200) -> list:
57
+ """List flow runs."""
58
+ return api.flow_runs(state=state, limit=limit) # any JSON-able value
59
+ ```
60
+
61
+ If the 200 runs fit in 20,000 tokens the model gets them unchanged. If not, it gets as many
62
+ whole runs as fit, plus a `page` object:
63
+
64
+ ```json
65
+ {"items": [{"id": "...", "name": "..."}, ...],
66
+ "page": {"items": "1-37 of 200", "total": 200, "next_cursor": "MTozNzo1YzBmYTE5Mw",
67
+ "note": "Showing items 1-37 of 200. Call this tool again with the same arguments and cursor=\"MTozNzo1YzBmYTE5Mw\" for the next page."}}
68
+ ```
69
+
70
+ The model calls `get_flow_runs(state="COMPLETED", limit=200, cursor="MTozNzo1YzBmYTE5Mw")` and
71
+ gets items 38 onward. The decorator also adds one sentence about `next_cursor` to the tool's
72
+ description. It works on sync and async tools, keeps `Context` parameters hidden from the model
73
+ as before, and resolves string annotations (`from __future__ import annotations`).
74
+
75
+ Without the decorator, call it yourself. This is the way to go when you want the cursor in your
76
+ own signature, or to page only some of the result:
77
+
78
+ ```python
79
+ @mcp.tool(structured_output=False)
80
+ def read_log(run_id: str, cursor: str = "") -> str:
81
+ """Read a flow run's log."""
82
+ return cap.page(api.log_text(run_id), cursor)
83
+ ```
84
+
85
+ ## What it does with each kind of result
86
+
87
+ | Result | What the model gets when it is too big |
88
+ | --- | --- |
89
+ | A list | A page of whole items, wrapped as `{"items": [...], "page": {...}}` |
90
+ | 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"` |
91
+ | 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="..."]` |
92
+ | 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 |
93
+ | Anything else (a dict without lists, a huge string field) | Every key kept, long strings and lists cut until it fits |
94
+
95
+ `cap.trim(data)` does the same without cursors, for tools where paging makes no sense: the
96
+ model gets the first part and a note saying how many items were left out and to narrow the
97
+ request. Pydantic models, dataclasses, tuples and sets are converted to JSON first.
98
+
99
+ ## Settings
100
+
101
+ ```python
102
+ Cap(
103
+ max_tokens=None, # default: the TOOLCAP_MAX_TOKENS env var, else 20,000
104
+ count=None, # text -> token count; default is a high estimate (UTF-8 bytes / 3)
105
+ indent=None, # JSON indent; None (compact) uses the fewest tokens
106
+ cursor_param="cursor", # name of the parameter the decorator adds
107
+ )
108
+ ```
109
+
110
+ For an exact fit, pass a real tokenizer: `Cap(count=lambda s: len(enc.encode(s)))`. The default
111
+ estimate counts one token per 3 bytes of UTF-8, which is above what real tokenizers give for
112
+ English, JSON, code and CJK text, so pages come out a little smaller than they could be but
113
+ never over the limit. Users of your server can change the budget with `TOOLCAP_MAX_TOKENS`
114
+ without code changes (match it to `MAX_MCP_OUTPUT_TOKENS` if they raised that in Claude Code).
115
+
116
+ Module-level shortcuts with default settings: `toolcap.page(data, cursor)`,
117
+ `toolcap.trim(data)` and `@toolcap.paginate`.
118
+
119
+ ## How paging works, and its limits
120
+
121
+ - Cursors are stateless: an offset plus a short fingerprint of the full result. The server runs
122
+ the tool again on every page and slices it, so nothing is stored between calls and it works
123
+ across processes and restarts. The cost is that each page re-runs the query; for an expensive
124
+ backend, cache the result yourself or pass the cursor's offset on to the backend.
125
+ - If the result changed between pages (new rows came in), the page's note says so and that
126
+ items may be repeated or missed. Give results a stable sort order.
127
+ - A cursor that toolcap did not issue is rejected with an error the model can act on ("call the
128
+ tool without a cursor to start from the beginning"). It is the SDK's own `ToolError`, so the
129
+ text reaches the model on SDK 2.x too.
130
+ - The decorated tool returns text and declares no output schema, so the SDKs don't also copy
131
+ the page into `structuredContent` (which would double its size, and some clients skip their
132
+ truncation for structured content). Use `structured_output=False` on hand-written `-> str`
133
+ tools for the same reason.
134
+ - The budget covers one result. It does not know how full the model's context already is.
135
+ - Lists are paged at one level (the largest list reached through dict keys). Lists inside list
136
+ items are cut, not paged.
137
+
138
+ ## Why this exists
139
+
140
+ MCP servers keep running into the client's result-size limit, and each one fixes it by hand
141
+ with ad-hoc `limit` parameters or a second "minimal" tool:
142
+
143
+ - [strands-agents/harness-sdk#5862](https://github.com/strands-agents/harness-sdk/issues/5862):
144
+ "enforce a response size cap with truncation and continuation"
145
+ - [microsoft/playwright-mcp#395](https://github.com/microsoft/playwright-mcp/issues/395) (14 👍):
146
+ "the snapshot length is too long, add pagination to it"
147
+ - [OpsLevel/opslevel-mcp#47](https://github.com/OpsLevel/opslevel-mcp/issues/47) (13 👍):
148
+ filters to avoid response size errors
149
+ - [PrefectHQ/prefect-mcp-server#125](https://github.com/PrefectHQ/prefect-mcp-server/issues/125):
150
+ `get_flow_runs` returning ~261k tokens
151
+ - [eyaltoledano/claude-task-master#1137](https://github.com/eyaltoledano/claude-task-master/issues/1137):
152
+ pagination for `get_tasks`
153
+ - [mui/material-ui#46778](https://github.com/mui/material-ui/issues/46778): MCP tool "returning
154
+ too many tokens for Claude Code"
155
+
156
+ ## License
157
+
158
+ MIT
@@ -0,0 +1,131 @@
1
+ # toolcap
2
+
3
+ Keep MCP tool results under the client's token limit, with pages the model can follow.
4
+
5
+ A tool that returns 200 flow runs, a long log or a big page snapshot can blow past the client's
6
+ limit (Claude Code rejects results over 25,000 tokens) or fill the context in one call. toolcap
7
+ cuts any result into pages of whole items that fit a token budget, each with a `next_cursor`
8
+ and a note telling the model how to get the next page, in one call or one decorator.
9
+
10
+ ```bash
11
+ pip install toolcap
12
+ ```
13
+
14
+ Python 3.9+, no dependencies. Works with any MCP framework (the official MCP Python SDK 1.x
15
+ `FastMCP` and 2.x `MCPServer`, the standalone `fastmcp`) or none, because it only turns a value
16
+ into text.
17
+
18
+ ## Example
19
+
20
+ ```python
21
+ from mcp.server.fastmcp import FastMCP # SDK 2.x: from mcp.server.mcpserver import MCPServer
22
+ from toolcap import Cap
23
+
24
+ mcp = FastMCP("prefect")
25
+ cap = Cap(max_tokens=20_000)
26
+
27
+ @mcp.tool()
28
+ @cap.paginate # adds a `cursor: str = ""` parameter for the model
29
+ def get_flow_runs(state: str = "COMPLETED", limit: int = 200) -> list:
30
+ """List flow runs."""
31
+ return api.flow_runs(state=state, limit=limit) # any JSON-able value
32
+ ```
33
+
34
+ If the 200 runs fit in 20,000 tokens the model gets them unchanged. If not, it gets as many
35
+ whole runs as fit, plus a `page` object:
36
+
37
+ ```json
38
+ {"items": [{"id": "...", "name": "..."}, ...],
39
+ "page": {"items": "1-37 of 200", "total": 200, "next_cursor": "MTozNzo1YzBmYTE5Mw",
40
+ "note": "Showing items 1-37 of 200. Call this tool again with the same arguments and cursor=\"MTozNzo1YzBmYTE5Mw\" for the next page."}}
41
+ ```
42
+
43
+ The model calls `get_flow_runs(state="COMPLETED", limit=200, cursor="MTozNzo1YzBmYTE5Mw")` and
44
+ gets items 38 onward. The decorator also adds one sentence about `next_cursor` to the tool's
45
+ description. It works on sync and async tools, keeps `Context` parameters hidden from the model
46
+ as before, and resolves string annotations (`from __future__ import annotations`).
47
+
48
+ Without the decorator, call it yourself. This is the way to go when you want the cursor in your
49
+ own signature, or to page only some of the result:
50
+
51
+ ```python
52
+ @mcp.tool(structured_output=False)
53
+ def read_log(run_id: str, cursor: str = "") -> str:
54
+ """Read a flow run's log."""
55
+ return cap.page(api.log_text(run_id), cursor)
56
+ ```
57
+
58
+ ## What it does with each kind of result
59
+
60
+ | Result | What the model gets when it is too big |
61
+ | --- | --- |
62
+ | A list | A page of whole items, wrapped as `{"items": [...], "page": {...}}` |
63
+ | 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"` |
64
+ | 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="..."]` |
65
+ | 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 |
66
+ | Anything else (a dict without lists, a huge string field) | Every key kept, long strings and lists cut until it fits |
67
+
68
+ `cap.trim(data)` does the same without cursors, for tools where paging makes no sense: the
69
+ model gets the first part and a note saying how many items were left out and to narrow the
70
+ request. Pydantic models, dataclasses, tuples and sets are converted to JSON first.
71
+
72
+ ## Settings
73
+
74
+ ```python
75
+ Cap(
76
+ max_tokens=None, # default: the TOOLCAP_MAX_TOKENS env var, else 20,000
77
+ count=None, # text -> token count; default is a high estimate (UTF-8 bytes / 3)
78
+ indent=None, # JSON indent; None (compact) uses the fewest tokens
79
+ cursor_param="cursor", # name of the parameter the decorator adds
80
+ )
81
+ ```
82
+
83
+ For an exact fit, pass a real tokenizer: `Cap(count=lambda s: len(enc.encode(s)))`. The default
84
+ estimate counts one token per 3 bytes of UTF-8, which is above what real tokenizers give for
85
+ English, JSON, code and CJK text, so pages come out a little smaller than they could be but
86
+ never over the limit. Users of your server can change the budget with `TOOLCAP_MAX_TOKENS`
87
+ without code changes (match it to `MAX_MCP_OUTPUT_TOKENS` if they raised that in Claude Code).
88
+
89
+ Module-level shortcuts with default settings: `toolcap.page(data, cursor)`,
90
+ `toolcap.trim(data)` and `@toolcap.paginate`.
91
+
92
+ ## How paging works, and its limits
93
+
94
+ - Cursors are stateless: an offset plus a short fingerprint of the full result. The server runs
95
+ the tool again on every page and slices it, so nothing is stored between calls and it works
96
+ across processes and restarts. The cost is that each page re-runs the query; for an expensive
97
+ backend, cache the result yourself or pass the cursor's offset on to the backend.
98
+ - If the result changed between pages (new rows came in), the page's note says so and that
99
+ items may be repeated or missed. Give results a stable sort order.
100
+ - A cursor that toolcap did not issue is rejected with an error the model can act on ("call the
101
+ tool without a cursor to start from the beginning"). It is the SDK's own `ToolError`, so the
102
+ text reaches the model on SDK 2.x too.
103
+ - The decorated tool returns text and declares no output schema, so the SDKs don't also copy
104
+ the page into `structuredContent` (which would double its size, and some clients skip their
105
+ truncation for structured content). Use `structured_output=False` on hand-written `-> str`
106
+ tools for the same reason.
107
+ - The budget covers one result. It does not know how full the model's context already is.
108
+ - Lists are paged at one level (the largest list reached through dict keys). Lists inside list
109
+ items are cut, not paged.
110
+
111
+ ## Why this exists
112
+
113
+ MCP servers keep running into the client's result-size limit, and each one fixes it by hand
114
+ with ad-hoc `limit` parameters or a second "minimal" tool:
115
+
116
+ - [strands-agents/harness-sdk#5862](https://github.com/strands-agents/harness-sdk/issues/5862):
117
+ "enforce a response size cap with truncation and continuation"
118
+ - [microsoft/playwright-mcp#395](https://github.com/microsoft/playwright-mcp/issues/395) (14 👍):
119
+ "the snapshot length is too long, add pagination to it"
120
+ - [OpsLevel/opslevel-mcp#47](https://github.com/OpsLevel/opslevel-mcp/issues/47) (13 👍):
121
+ filters to avoid response size errors
122
+ - [PrefectHQ/prefect-mcp-server#125](https://github.com/PrefectHQ/prefect-mcp-server/issues/125):
123
+ `get_flow_runs` returning ~261k tokens
124
+ - [eyaltoledano/claude-task-master#1137](https://github.com/eyaltoledano/claude-task-master/issues/1137):
125
+ pagination for `get_tasks`
126
+ - [mui/material-ui#46778](https://github.com/mui/material-ui/issues/46778): MCP tool "returning
127
+ too many tokens for Claude Code"
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "toolcap"
7
+ version = "0.1.0"
8
+ description = "Keep MCP tool results under the client's token limit (Claude Code's 25,000): cursor pagination of long lists and text, or safe truncation, in one call or decorator"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Sri Krishna Lingamaneni" }]
14
+ keywords = [
15
+ "mcp", "model context protocol", "mcp server", "tool result", "response size", "token limit",
16
+ "max_mcp_output_tokens", "pagination", "cursor", "truncate", "context window", "fastmcp",
17
+ "llm", "agents",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Topic :: Software Development :: Libraries",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest>=7", "pydantic>=2", "anyio>=4", "mcp>=1.10; python_version >= '3.10'"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/srikrishnaL/kitforge/tree/main/packages/toolcap"
35
+ Source = "https://github.com/srikrishnaL/kitforge"
36
+ Issues = "https://github.com/srikrishnaL/kitforge/issues"
37
+ Changelog = "https://github.com/srikrishnaL/kitforge/blob/main/packages/toolcap/CHANGELOG.md"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/toolcap"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
@@ -0,0 +1,49 @@
1
+ """toolcap: keep MCP tool results under the client's token limit, with cursor pagination.
2
+
3
+ >>> from toolcap import Cap
4
+ >>> cap = Cap(max_tokens=20_000)
5
+ >>> text = cap.page(rows, cursor) # a page of whole items + next_cursor
6
+ >>> text = cap.trim(rows) # first part only, with a note on what was cut
7
+
8
+ Or as a decorator under ``@mcp.tool()``: ``@cap.paginate``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Callable, Optional, Sequence, Union
14
+
15
+ from .core import DEFAULT_MAX_TOKENS, Cap, decode_cursor, encode_cursor, estimate_tokens, jsonable
16
+ from .errors import CursorError
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "Cap",
22
+ "CursorError",
23
+ "DEFAULT_MAX_TOKENS",
24
+ "decode_cursor",
25
+ "encode_cursor",
26
+ "estimate_tokens",
27
+ "jsonable",
28
+ "page",
29
+ "paginate",
30
+ "trim",
31
+ ]
32
+
33
+ Key = Union[str, Sequence[str], None]
34
+
35
+
36
+ def page(data: Any, cursor: Optional[str] = "", *, max_tokens: Optional[int] = None, key: Key = None) -> str:
37
+ """``Cap(max_tokens).page(data, cursor)``: one page of ``data`` that fits the budget."""
38
+ return Cap(max_tokens).page(data, cursor, key=key)
39
+
40
+
41
+ def trim(data: Any, *, max_tokens: Optional[int] = None, key: Key = None) -> str:
42
+ """``Cap(max_tokens).trim(data)``: ``data`` cut down to fit, with a note on what was left out."""
43
+ return Cap(max_tokens).trim(data, key=key)
44
+
45
+
46
+ def paginate(fn: Optional[Callable] = None, *, max_tokens: Optional[int] = None, key: Key = None):
47
+ """``@paginate`` / ``@paginate(max_tokens=...)``: decorator form of :func:`page`."""
48
+ cap = Cap(max_tokens)
49
+ return cap.paginate(fn, key=key)