toolcap 0.1.0__py3-none-any.whl
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.
- toolcap/__init__.py +49 -0
- toolcap/core.py +489 -0
- toolcap/errors.py +34 -0
- toolcap-0.1.0.dist-info/METADATA +158 -0
- toolcap-0.1.0.dist-info/RECORD +7 -0
- toolcap-0.1.0.dist-info/WHEEL +4 -0
- toolcap-0.1.0.dist-info/licenses/LICENSE +21 -0
toolcap/__init__.py
ADDED
|
@@ -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)
|
toolcap/core.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Fit tool results under a token budget, with cursor pagination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import dataclasses
|
|
8
|
+
import functools
|
|
9
|
+
import hashlib
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import typing
|
|
14
|
+
from typing import Any, Callable, List, Optional, Sequence, Tuple, Union
|
|
15
|
+
|
|
16
|
+
from .errors import CursorError
|
|
17
|
+
|
|
18
|
+
#: Claude Code refuses MCP results over 25,000 tokens by default; stay under it.
|
|
19
|
+
DEFAULT_MAX_TOKENS = 20_000
|
|
20
|
+
MIN_TOKENS = 100
|
|
21
|
+
|
|
22
|
+
Path = Tuple[str, ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def estimate_tokens(text: str) -> int:
|
|
26
|
+
"""A deliberately high token estimate: one token per 3 bytes of UTF-8.
|
|
27
|
+
|
|
28
|
+
Real tokenizers average about 4 characters per token for English prose,
|
|
29
|
+
fewer for JSON, code and IDs, and about 1 token per character for Chinese,
|
|
30
|
+
Japanese or Korean (3 UTF-8 bytes each). Counting bytes / 3 stays at or
|
|
31
|
+
above all of these, so a result that fits here fits in the client.
|
|
32
|
+
"""
|
|
33
|
+
return -(-len(text.encode("utf-8")) // 3)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
# JSON conversion
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def jsonable(value: Any) -> Any:
|
|
41
|
+
"""Convert pydantic models, dataclasses, tuples and sets into plain JSON values."""
|
|
42
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
43
|
+
return value
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return {str(k): jsonable(v) for k, v in value.items()}
|
|
46
|
+
if isinstance(value, (list, tuple)):
|
|
47
|
+
return [jsonable(v) for v in value]
|
|
48
|
+
if isinstance(value, (set, frozenset)):
|
|
49
|
+
items = [jsonable(v) for v in value]
|
|
50
|
+
try:
|
|
51
|
+
return sorted(items)
|
|
52
|
+
except TypeError:
|
|
53
|
+
return items
|
|
54
|
+
dump = getattr(value, "model_dump", None) # pydantic v2
|
|
55
|
+
if callable(dump):
|
|
56
|
+
return jsonable(dump(mode="json"))
|
|
57
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
58
|
+
return jsonable(dataclasses.asdict(value))
|
|
59
|
+
return str(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
# Cursors
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fingerprint(text: str) -> str:
|
|
67
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def encode_cursor(offset: int, fingerprint: str) -> str:
|
|
71
|
+
raw = f"1:{offset}:{fingerprint}".encode("ascii")
|
|
72
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def decode_cursor(cursor: str) -> Tuple[int, str]:
|
|
76
|
+
"""Return ``(offset, fingerprint)``; raise :class:`CursorError` for anything else."""
|
|
77
|
+
bad = CursorError(
|
|
78
|
+
f"Invalid cursor {cursor[:40]!r}. Pass the next_cursor value from the previous result "
|
|
79
|
+
"exactly as it was given, or call the tool without a cursor to start from the beginning."
|
|
80
|
+
)
|
|
81
|
+
text = cursor.strip()
|
|
82
|
+
try:
|
|
83
|
+
raw = base64.b64decode(text + "=" * (-len(text) % 4), altchars=b"-_", validate=True).decode("ascii")
|
|
84
|
+
except (binascii.Error, ValueError, UnicodeDecodeError):
|
|
85
|
+
raise bad from None
|
|
86
|
+
parts = raw.split(":")
|
|
87
|
+
if len(parts) != 3 or parts[0] != "1" or not parts[1].isdigit():
|
|
88
|
+
raise bad
|
|
89
|
+
return int(parts[1]), parts[2]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------- #
|
|
93
|
+
# Shrinking one value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cut_strings(value: Any, limit: int) -> Any:
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
if len(value) <= limit:
|
|
99
|
+
return value
|
|
100
|
+
return value[:limit] + f"... [{len(value) - limit} more characters cut]"
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
return {k: _cut_strings(v, limit) for k, v in value.items()}
|
|
103
|
+
if isinstance(value, list):
|
|
104
|
+
return [_cut_strings(v, limit) for v in value]
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cut_lists(value: Any, limit: int) -> Any:
|
|
109
|
+
if isinstance(value, dict):
|
|
110
|
+
return {k: _cut_lists(v, limit) for k, v in value.items()}
|
|
111
|
+
if isinstance(value, list):
|
|
112
|
+
head = [_cut_lists(v, limit) for v in value[:limit]]
|
|
113
|
+
if len(value) > limit:
|
|
114
|
+
head.append(f"... [{len(value) - limit} more items cut]")
|
|
115
|
+
return head
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _longest(value: Any, kind: type) -> int:
|
|
120
|
+
if isinstance(value, kind) and kind is str:
|
|
121
|
+
return len(value)
|
|
122
|
+
best = len(value) if isinstance(value, kind) else 0
|
|
123
|
+
children = value.values() if isinstance(value, dict) else value if isinstance(value, list) else ()
|
|
124
|
+
for child in children:
|
|
125
|
+
best = max(best, _longest(child, kind))
|
|
126
|
+
return best
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _largest_fit(lo: int, hi: int, fits: Callable[[int], bool]) -> Optional[int]:
|
|
130
|
+
"""Largest n in [lo, hi] with fits(n), assuming fits is monotonic; None if none fits."""
|
|
131
|
+
if not fits(lo):
|
|
132
|
+
return None
|
|
133
|
+
while lo < hi:
|
|
134
|
+
mid = (lo + hi + 1) // 2
|
|
135
|
+
if fits(mid):
|
|
136
|
+
lo = mid
|
|
137
|
+
else:
|
|
138
|
+
hi = mid - 1
|
|
139
|
+
return lo
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def shrink(value: Any, fits: Callable[[Any], bool]) -> Tuple[Any, bool]:
|
|
143
|
+
"""Cut long strings, then long lists, until ``fits(value)``. Returns (value, was_cut).
|
|
144
|
+
|
|
145
|
+
Every key stays, so the shape the caller expects is kept.
|
|
146
|
+
"""
|
|
147
|
+
if fits(value):
|
|
148
|
+
return value, False
|
|
149
|
+
longest = _longest(value, str)
|
|
150
|
+
if longest > 0:
|
|
151
|
+
limit = _largest_fit(0, longest, lambda n: fits(_cut_strings(value, n)))
|
|
152
|
+
if limit is not None:
|
|
153
|
+
return _cut_strings(value, limit), True
|
|
154
|
+
value = _cut_strings(value, 0)
|
|
155
|
+
longest = _longest(value, list)
|
|
156
|
+
limit = _largest_fit(0, longest, lambda n: fits(_cut_lists(value, n)))
|
|
157
|
+
if limit is not None:
|
|
158
|
+
return _cut_lists(value, limit), True
|
|
159
|
+
return _cut_lists(value, 0), True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
# Locating the list to page through
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get(value: Any, path: Path) -> Any:
|
|
167
|
+
for key in path:
|
|
168
|
+
value = value[key]
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _replace(value: Any, path: Path, new: Any) -> Any:
|
|
173
|
+
if not path:
|
|
174
|
+
return new
|
|
175
|
+
out = dict(value)
|
|
176
|
+
out[path[0]] = _replace(value[path[0]], path[1:], new)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _find_list(value: Any, path: Path = ()) -> Optional[Path]:
|
|
181
|
+
"""Path (through dict keys only) to the list with the largest JSON size."""
|
|
182
|
+
if isinstance(value, list):
|
|
183
|
+
return path
|
|
184
|
+
if not isinstance(value, dict):
|
|
185
|
+
return None
|
|
186
|
+
best: Optional[Path] = None
|
|
187
|
+
best_size = -1
|
|
188
|
+
for key, child in value.items():
|
|
189
|
+
found = _find_list(child, path + (key,))
|
|
190
|
+
if found is None:
|
|
191
|
+
continue
|
|
192
|
+
size = len(json.dumps(_get(value, found[len(path):]), ensure_ascii=False, default=str))
|
|
193
|
+
if size > best_size:
|
|
194
|
+
best, best_size = found, size
|
|
195
|
+
return best
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --------------------------------------------------------------------------- #
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Cap:
|
|
202
|
+
"""Keep tool results under ``max_tokens``, paging through long lists and text.
|
|
203
|
+
|
|
204
|
+
``max_tokens``: the budget for one result. Defaults to the ``TOOLCAP_MAX_TOKENS``
|
|
205
|
+
environment variable, else 20,000 (Claude Code rejects results over 25,000).
|
|
206
|
+
``count``: a function from text to token count; defaults to :func:`estimate_tokens`.
|
|
207
|
+
Pass a real tokenizer (``lambda s: len(enc.encode(s))``) for a tighter fit.
|
|
208
|
+
``indent``: JSON indentation of results; ``None`` (compact) uses the fewest tokens.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
max_tokens: Optional[int] = None,
|
|
214
|
+
*,
|
|
215
|
+
count: Optional[Callable[[str], int]] = None,
|
|
216
|
+
indent: Optional[int] = None,
|
|
217
|
+
cursor_param: str = "cursor",
|
|
218
|
+
):
|
|
219
|
+
if max_tokens is None:
|
|
220
|
+
env = os.environ.get("TOOLCAP_MAX_TOKENS", "").strip()
|
|
221
|
+
if env and not env.isdigit():
|
|
222
|
+
raise ValueError(f"TOOLCAP_MAX_TOKENS must be a whole number, got {env!r}")
|
|
223
|
+
max_tokens = int(env) if env else DEFAULT_MAX_TOKENS
|
|
224
|
+
if max_tokens < MIN_TOKENS:
|
|
225
|
+
raise ValueError(f"max_tokens must be at least {MIN_TOKENS}, got {max_tokens}")
|
|
226
|
+
if not cursor_param.isidentifier():
|
|
227
|
+
raise ValueError(f"cursor_param must be a valid parameter name, got {cursor_param!r}")
|
|
228
|
+
self.max_tokens = max_tokens
|
|
229
|
+
self.count = count or estimate_tokens
|
|
230
|
+
self.indent = indent
|
|
231
|
+
self.cursor_param = cursor_param
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------ helpers
|
|
234
|
+
|
|
235
|
+
def fits(self, text: str) -> bool:
|
|
236
|
+
return self.count(text) <= self.max_tokens
|
|
237
|
+
|
|
238
|
+
def _dumps(self, value: Any) -> str:
|
|
239
|
+
if self.indent is None:
|
|
240
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
241
|
+
return json.dumps(value, ensure_ascii=False, indent=self.indent, default=str)
|
|
242
|
+
|
|
243
|
+
def _again(self, cursor: str) -> str:
|
|
244
|
+
return (f"Call this tool again with the same arguments and {self.cursor_param}="
|
|
245
|
+
f'"{cursor}" for the next page.')
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ public
|
|
248
|
+
|
|
249
|
+
def page(self, data: Any, cursor: Optional[str] = "", *, key: Union[str, Sequence[str], None] = None) -> str:
|
|
250
|
+
"""Return ``data`` as text that fits the budget.
|
|
251
|
+
|
|
252
|
+
Lists (or the largest list inside a dict, or the one at ``key``, a dotted path
|
|
253
|
+
such as ``"data.items"``) are split into pages of whole items; text is split
|
|
254
|
+
at line breaks. Each page says how to get the next one. Pass the ``cursor``
|
|
255
|
+
the model sends back to get that page. Results that already fit are returned
|
|
256
|
+
unchanged (text as is, everything else as JSON).
|
|
257
|
+
"""
|
|
258
|
+
return self._fit(data, cursor or "", key, paginate=True)
|
|
259
|
+
|
|
260
|
+
def trim(self, data: Any, *, key: Union[str, Sequence[str], None] = None) -> str:
|
|
261
|
+
"""Like :meth:`page`, but without cursors: the rest is cut, with a note saying how much."""
|
|
262
|
+
return self._fit(data, "", key, paginate=False)
|
|
263
|
+
|
|
264
|
+
def paginate(self, fn: Optional[Callable] = None, *, key: Union[str, Sequence[str], None] = None):
|
|
265
|
+
"""Decorator: run the tool, then return :meth:`page` of its result.
|
|
266
|
+
|
|
267
|
+
Adds a ``cursor: str = ""`` parameter (named ``cursor_param``) to the tool's
|
|
268
|
+
signature unless it has one, and mentions it in the docstring, so MCP
|
|
269
|
+
frameworks show it to the model. Works on sync and async functions; put it
|
|
270
|
+
below ``@mcp.tool()``.
|
|
271
|
+
"""
|
|
272
|
+
if fn is None:
|
|
273
|
+
return lambda f: self.paginate(f, key=key)
|
|
274
|
+
return _wrap(self, fn, key)
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------ fitting
|
|
277
|
+
|
|
278
|
+
def _fit(self, data: Any, cursor: str, key: Any, paginate: bool) -> str:
|
|
279
|
+
offset, seen = decode_cursor(cursor) if cursor else (0, None)
|
|
280
|
+
if isinstance(data, str):
|
|
281
|
+
return self._fit_text(data, offset, seen, paginate)
|
|
282
|
+
value = jsonable(data)
|
|
283
|
+
full = self._dumps(value)
|
|
284
|
+
if offset == 0 and self.fits(full):
|
|
285
|
+
return full
|
|
286
|
+
path: Optional[Path]
|
|
287
|
+
if key is not None:
|
|
288
|
+
path = tuple(key.split(".")) if isinstance(key, str) else tuple(key)
|
|
289
|
+
try:
|
|
290
|
+
target = _get(value, path)
|
|
291
|
+
except (KeyError, TypeError, IndexError):
|
|
292
|
+
raise KeyError(f"toolcap: no value at key {'.'.join(path)!r}") from None
|
|
293
|
+
if not isinstance(target, list):
|
|
294
|
+
raise TypeError(f"toolcap: the value at key {'.'.join(path)!r} is not a list")
|
|
295
|
+
else:
|
|
296
|
+
path = _find_list(value)
|
|
297
|
+
if path is None: # nothing to page through: cut it down instead
|
|
298
|
+
return self._dumps(self._shrink_all(value))
|
|
299
|
+
return self._fit_list(value, path, offset, seen, _fingerprint(full), paginate)
|
|
300
|
+
|
|
301
|
+
def _shrink_all(self, value: Any) -> Any:
|
|
302
|
+
out, _ = shrink(value, lambda v: self.fits(self._dumps(v)))
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
def _meta_key(self, value: Any, path: Path) -> Tuple[Any, Path, str]:
|
|
306
|
+
"""Where pagination info goes: next to the list, without clobbering a real key."""
|
|
307
|
+
if not path: # a bare list: wrap it
|
|
308
|
+
return {"items": value}, ("items",), "page"
|
|
309
|
+
name = "page"
|
|
310
|
+
while name in value:
|
|
311
|
+
name = "_" + name
|
|
312
|
+
return value, path, name
|
|
313
|
+
|
|
314
|
+
def _fit_list(self, value: Any, path: Path, offset: int, seen: Optional[str],
|
|
315
|
+
fp: str, paginate: bool) -> str:
|
|
316
|
+
container, path, meta_key = self._meta_key(value, path)
|
|
317
|
+
items: List[Any] = _get(container, path)
|
|
318
|
+
total = len(items)
|
|
319
|
+
changed = seen is not None and seen != fp
|
|
320
|
+
state = {"container": container}
|
|
321
|
+
|
|
322
|
+
def render(n: int, cut_item: Any = None) -> str:
|
|
323
|
+
shown = items[offset:offset + n] if cut_item is None else [cut_item]
|
|
324
|
+
end = offset + len(shown)
|
|
325
|
+
meta = {"items": f"{offset + 1}-{end} of {total}" if shown else f"0 of {total}",
|
|
326
|
+
"total": total}
|
|
327
|
+
notes = []
|
|
328
|
+
if changed:
|
|
329
|
+
notes.append("The results changed since the previous page, so items may be "
|
|
330
|
+
"repeated or missed; call without a cursor to start over.")
|
|
331
|
+
if cut_item is not None:
|
|
332
|
+
notes.append(f"Item {offset + 1} was too large and was cut to fit.")
|
|
333
|
+
if end < total:
|
|
334
|
+
if paginate:
|
|
335
|
+
nxt = encode_cursor(end, fp)
|
|
336
|
+
meta["next_cursor"] = nxt
|
|
337
|
+
notes.append(f"Showing items {offset + 1}-{end} of {total}. " + self._again(nxt))
|
|
338
|
+
else:
|
|
339
|
+
notes.append(f"Showing the first {end} of {total} items; {total - end} more "
|
|
340
|
+
"were left out to fit the size limit. Ask for fewer results "
|
|
341
|
+
"(filters, a smaller limit) to see the rest.")
|
|
342
|
+
elif offset >= total:
|
|
343
|
+
notes.append("No more items." if total else "No items.")
|
|
344
|
+
else:
|
|
345
|
+
meta["next_cursor"] = None
|
|
346
|
+
if offset:
|
|
347
|
+
notes.append(f"Showing items {offset + 1}-{end} of {total}. This is the last page.")
|
|
348
|
+
if notes:
|
|
349
|
+
meta["note"] = " ".join(notes)
|
|
350
|
+
out = _replace(state["container"], path, shown)
|
|
351
|
+
if isinstance(out, dict):
|
|
352
|
+
out = dict(out)
|
|
353
|
+
out[meta_key] = meta
|
|
354
|
+
return self._dumps(out)
|
|
355
|
+
|
|
356
|
+
if not self.fits(render(0)):
|
|
357
|
+
# The fields around the list are over budget on their own: cut them to half
|
|
358
|
+
# the budget, leaving the other half for the items.
|
|
359
|
+
half = self.max_tokens // 2
|
|
360
|
+
base, _ = shrink(_replace(container, path, []),
|
|
361
|
+
lambda b: self.count(self._dumps(dict(b, **{meta_key: {}}))) <= half)
|
|
362
|
+
state["container"] = _replace(base, path, items)
|
|
363
|
+
if offset >= total:
|
|
364
|
+
return render(0)
|
|
365
|
+
|
|
366
|
+
remaining = total - offset
|
|
367
|
+
# Grow the page exponentially, then binary-search the edge, so the cost is
|
|
368
|
+
# proportional to the page size rather than to the whole result.
|
|
369
|
+
hi = 1
|
|
370
|
+
while hi < remaining and self.fits(render(hi)):
|
|
371
|
+
hi = min(hi * 2, remaining)
|
|
372
|
+
if self.fits(render(hi)):
|
|
373
|
+
return render(hi)
|
|
374
|
+
n = _largest_fit(1, hi, lambda k: self.fits(render(k)))
|
|
375
|
+
if n is not None:
|
|
376
|
+
return render(n)
|
|
377
|
+
# Not even one whole item fits: cut that item down so the page still moves on.
|
|
378
|
+
cut, _ = shrink(items[offset], lambda v: self.fits(render(1, cut_item=v)))
|
|
379
|
+
return render(1, cut_item=cut)
|
|
380
|
+
|
|
381
|
+
def _fit_text(self, text: str, offset: int, seen: Optional[str], paginate: bool) -> str:
|
|
382
|
+
if offset == 0 and self.fits(text):
|
|
383
|
+
return text
|
|
384
|
+
fp = _fingerprint(text)
|
|
385
|
+
total = len(text)
|
|
386
|
+
changed = seen is not None and seen != fp
|
|
387
|
+
|
|
388
|
+
def footer(end: int) -> str:
|
|
389
|
+
notes = []
|
|
390
|
+
if changed:
|
|
391
|
+
notes.append("The text changed since the previous page; call without a cursor to "
|
|
392
|
+
"start over.")
|
|
393
|
+
if offset >= total:
|
|
394
|
+
notes.append("No more text.")
|
|
395
|
+
elif end < total:
|
|
396
|
+
where = f"characters {offset + 1}-{end} of {total}"
|
|
397
|
+
if paginate:
|
|
398
|
+
notes.append(f"Output cut: showing {where}. " + self._again(encode_cursor(end, fp)))
|
|
399
|
+
else:
|
|
400
|
+
notes.append(f"Output cut: showing {where}; the rest was left out to fit the "
|
|
401
|
+
"size limit.")
|
|
402
|
+
elif offset:
|
|
403
|
+
notes.append(f"Showing characters {offset + 1}-{end} of {total}. This is the end.")
|
|
404
|
+
return "\n\n[" + " ".join(notes) + "]" if notes else ""
|
|
405
|
+
|
|
406
|
+
if offset >= total:
|
|
407
|
+
return footer(offset).lstrip("\n")
|
|
408
|
+
|
|
409
|
+
def render(end: int) -> str:
|
|
410
|
+
return text[offset:end] + footer(end)
|
|
411
|
+
|
|
412
|
+
end = _largest_fit(offset, total, lambda e: self.fits(render(e)))
|
|
413
|
+
if end is None or end == offset:
|
|
414
|
+
raise ValueError("toolcap: max_tokens is too small to hold even the page footer")
|
|
415
|
+
if end < total:
|
|
416
|
+
end = _break_at(text, offset, end)
|
|
417
|
+
return render(end)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _break_at(text: str, start: int, end: int) -> int:
|
|
421
|
+
"""Move ``end`` back to a line break (or else a space) if one is near the end of the page."""
|
|
422
|
+
window = max(1, (end - start) // 5)
|
|
423
|
+
for sep in ("\n", " "):
|
|
424
|
+
at = text.rfind(sep, end - window, end)
|
|
425
|
+
if at > start:
|
|
426
|
+
return at + 1
|
|
427
|
+
return end
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --------------------------------------------------------------------------- #
|
|
431
|
+
# Decorator
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _wrap(cap: Cap, fn: Callable, key: Any) -> Callable:
|
|
435
|
+
name = cap.cursor_param
|
|
436
|
+
sig = inspect.signature(fn)
|
|
437
|
+
try:
|
|
438
|
+
hints = typing.get_type_hints(fn, include_extras=True)
|
|
439
|
+
except Exception: # unresolvable forward references: keep the raw annotations
|
|
440
|
+
hints = dict(getattr(fn, "__annotations__", {}))
|
|
441
|
+
params = [p.replace(annotation=hints.get(p.name, p.annotation)) for p in sig.parameters.values()]
|
|
442
|
+
added = name not in sig.parameters
|
|
443
|
+
if added:
|
|
444
|
+
new = inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default="", annotation=str)
|
|
445
|
+
# Keep keyword-only parameters after positional ones, and **kwargs last.
|
|
446
|
+
idx = len(params)
|
|
447
|
+
for i, p in enumerate(params):
|
|
448
|
+
if p.kind == inspect.Parameter.VAR_KEYWORD:
|
|
449
|
+
idx = i
|
|
450
|
+
break
|
|
451
|
+
params.insert(idx, new)
|
|
452
|
+
# No return annotation: with ``-> str`` the MCP SDKs would also send the page as
|
|
453
|
+
# structuredContent ({"result": "..."}), doubling its size on the wire.
|
|
454
|
+
new_sig = sig.replace(parameters=params, return_annotation=inspect.Signature.empty)
|
|
455
|
+
|
|
456
|
+
def split(args: tuple, kwargs: dict) -> Tuple[tuple, dict, str]:
|
|
457
|
+
if added:
|
|
458
|
+
cursor = kwargs.pop(name, "")
|
|
459
|
+
else:
|
|
460
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
461
|
+
cursor = bound.arguments.get(name, sig.parameters[name].default)
|
|
462
|
+
if cursor is inspect.Parameter.empty:
|
|
463
|
+
cursor = ""
|
|
464
|
+
return args, kwargs, cursor or ""
|
|
465
|
+
|
|
466
|
+
if inspect.iscoroutinefunction(fn):
|
|
467
|
+
@functools.wraps(fn)
|
|
468
|
+
async def wrapper(*args, **kwargs):
|
|
469
|
+
args, kwargs, cursor = split(args, kwargs)
|
|
470
|
+
if cursor:
|
|
471
|
+
decode_cursor(cursor) # reject a bad cursor before doing the work
|
|
472
|
+
return cap.page(await fn(*args, **kwargs), cursor, key=key)
|
|
473
|
+
else:
|
|
474
|
+
@functools.wraps(fn)
|
|
475
|
+
def wrapper(*args, **kwargs):
|
|
476
|
+
args, kwargs, cursor = split(args, kwargs)
|
|
477
|
+
if cursor:
|
|
478
|
+
decode_cursor(cursor)
|
|
479
|
+
return cap.page(fn(*args, **kwargs), cursor, key=key)
|
|
480
|
+
|
|
481
|
+
wrapper.__signature__ = new_sig # type: ignore[attr-defined]
|
|
482
|
+
annotations = {p.name: p.annotation for p in params if p.annotation is not inspect.Parameter.empty}
|
|
483
|
+
wrapper.__annotations__ = annotations
|
|
484
|
+
del wrapper.__wrapped__ # frameworks must see the new signature, not the original
|
|
485
|
+
doc = inspect.getdoc(fn) or ""
|
|
486
|
+
hint = (f"Long results come in pages: if a result has a next_cursor, call again with the same "
|
|
487
|
+
f"arguments and {name} set to it.")
|
|
488
|
+
wrapper.__doc__ = f"{doc}\n\n{hint}" if doc else hint
|
|
489
|
+
return wrapper
|
toolcap/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Exceptions raised by toolcap.
|
|
2
|
+
|
|
3
|
+
``CursorError`` subclasses the MCP SDK's own ``ToolError`` classes (whichever
|
|
4
|
+
are installed), so the server sends its message to the model as the tool's
|
|
5
|
+
error result instead of hiding it as an internal error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from typing import Tuple, Type
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _tool_error_bases() -> Tuple[Type[Exception], ...]:
|
|
15
|
+
bases = []
|
|
16
|
+
for module in (
|
|
17
|
+
"mcp.server.mcpserver.exceptions", # MCP Python SDK 2.x (MCPServer)
|
|
18
|
+
"mcp.server.fastmcp.exceptions", # MCP Python SDK 1.x (FastMCP)
|
|
19
|
+
"fastmcp.exceptions", # the standalone fastmcp package
|
|
20
|
+
):
|
|
21
|
+
try:
|
|
22
|
+
cls = getattr(importlib.import_module(module), "ToolError")
|
|
23
|
+
except Exception: # not installed, or a version without it
|
|
24
|
+
continue
|
|
25
|
+
if isinstance(cls, type) and issubclass(cls, Exception) and cls not in bases:
|
|
26
|
+
bases.append(cls)
|
|
27
|
+
bases = [b for b in bases if not any(o is not b and issubclass(o, b) for o in bases)]
|
|
28
|
+
if not any(issubclass(b, ValueError) for b in bases):
|
|
29
|
+
bases.append(ValueError)
|
|
30
|
+
return tuple(bases)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CursorError(*_tool_error_bases()): # type: ignore[misc]
|
|
34
|
+
"""The cursor the model sent is not one toolcap issued. The message says how to recover."""
|
|
@@ -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,7 @@
|
|
|
1
|
+
toolcap/__init__.py,sha256=AMKglVQwR8enkyYLKtbkYoEvslHLuspPRP--bYXiQSQ,1609
|
|
2
|
+
toolcap/core.py,sha256=T9_4hWQavw2ibbwk17i6Mng2hqC0g6o8o-bRFEeF0Og,20014
|
|
3
|
+
toolcap/errors.py,sha256=CPaBg13Fctv4-K0lL63nBSf0Es3jPJYVEehCUnUf2OY,1302
|
|
4
|
+
toolcap-0.1.0.dist-info/METADATA,sha256=qkrlAAJIrfIyjuTtEOjJ6bYZi5zv2D-FNxiNS0w4uL8,8218
|
|
5
|
+
toolcap-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
toolcap-0.1.0.dist-info/licenses/LICENSE,sha256=fl9U1idgeyJw-8GWneEYxVzZM_C29cBR63IAlNv7KjE,1080
|
|
7
|
+
toolcap-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|