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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.