Generalizes the issue surfaced in #124 (unbounded tool outputs blowing up context windows) into an architectural invariant: every MCP tool whose output size depends on data must expose a caller-controlled bound and a way to fetch the remainder. Three sub-rules: no silent truncation, bound by domain shape (line/per-file/page), always resumable. - Add docs/design/output-bounding.md with rule, sub-rules, parameter vocabulary table, documentation contract, and a checklist for new tools. - Update AGENTS.md "Adding a New Tool" to require the checklist for any tool whose output is data-proportional. Tracked as forgejo-mcp-e0j. Complementary to the OpenSpec change add-bounded-text-responses, which delivers the rule for the specific tools called out in #124 (get_pull_request_diff, get_file_content).
95 lines
4.1 KiB
Markdown
95 lines
4.1 KiB
Markdown
# Output Bounding for MCP Tools
|
||
|
||
Architectural invariant for `forgejo-mcp` tool design. Any new tool that returns data
|
||
proportional to repository or upstream state MUST satisfy the rules below before
|
||
landing.
|
||
|
||
## Why
|
||
|
||
MCP tool outputs flow into an LLM context window. A single unbounded response
|
||
(diff, file content, commit list, log stream) can blow the window or silently
|
||
truncate at the transport envelope. The caller then sees partial data with no
|
||
signal and no way to fetch the remainder. Issue
|
||
[#124](https://codeberg.org/goern/forgejo-mcp/issues/124) surfaced this on
|
||
`get_pull_request_diff`, `get_file_content`, `list_pull_request_files`,
|
||
`list_pull_reviews`. This document generalizes the fix.
|
||
|
||
## The Rule
|
||
|
||
**Every tool output must be bounded by the caller, not the server. If the
|
||
output size depends on data rather than tool semantics, the tool MUST expose at
|
||
least one client-controlled bound AND a way to fetch the remainder.**
|
||
|
||
A tool whose output is bounded by its own semantics (e.g. `get_my_user_info`
|
||
returns one fixed-shape user object) is exempt. Everything else is in scope.
|
||
|
||
## Sub-rules
|
||
|
||
### 1. No silent truncation
|
||
|
||
A server-side envelope cap (e.g. 16 kB) without a caller-visible knob is a
|
||
trap: the caller receives partial data with no signal. Either:
|
||
|
||
- Expose the cap as a parameter the caller can raise / lower, or
|
||
- Replace the cap with proper paging / range params (sub-rule 2), or
|
||
- Return an explicit truncation marker (sub-rule 3) when the cap fires.
|
||
|
||
Never silently drop bytes.
|
||
|
||
### 2. Bound by domain shape, not bytes
|
||
|
||
Pick the natural unit for the data type. Byte ranges are a last-resort
|
||
fallback because they cut mid-token.
|
||
|
||
| Data type | Preferred bound | Parameter shape |
|
||
|---------------------------|-----------------------------------------|----------------------------------------------|
|
||
| Code / text file | Line range | `start_line`, `end_line` |
|
||
| Diff (multi-file) | Per-file slice (then optional paging) | `file_path`; index via `list_*_files` |
|
||
| List of entities | Page + limit | `page`, `limit` |
|
||
| Log stream | Tail / head + line or byte cap | `tail_bytes` (or `tail_lines`) + marker |
|
||
| Single binary blob | Byte range fallback | `offset`, `max_bytes` |
|
||
|
||
Reuse parameter names across tools — agents learn one vocabulary, not many.
|
||
|
||
### 3. Always resumable
|
||
|
||
When the caller hits the bound, the response must carry a continuation signal
|
||
so a follow-up call can retrieve the rest. Acceptable shapes:
|
||
|
||
- **Paging**: response includes `page`, `total_count`, or `has_next`.
|
||
- **Range**: response includes the range actually returned (e.g. lines 1–500
|
||
of 2300) so caller can issue the next slice.
|
||
- **Truncation marker**: a sentinel like `[truncated, N more bytes]` for log
|
||
/ byte-range tails when paging is unsuitable.
|
||
- **Index tool**: a sibling list tool (e.g. `list_pull_request_files`) so the
|
||
caller can enumerate slices before requesting any.
|
||
|
||
"Got 4 KB of N" beats "got 4 KB."
|
||
|
||
## Documentation contract
|
||
|
||
Every bound parameter MUST appear in:
|
||
|
||
1. The tool's `mcp.NewTool()` description (per-parameter doc).
|
||
2. The README tool table.
|
||
|
||
An undocumented cap is the same trap as no cap.
|
||
|
||
## Checklist for new tools
|
||
|
||
When adding a tool in `operation/{domain}/`, answer in the PR description:
|
||
|
||
- [ ] Is output size bounded by the tool's own semantics (one fixed-shape
|
||
object)? If yes, exempt — note this and skip the rest.
|
||
- [ ] If no: which bound parameter(s) does the tool expose?
|
||
- [ ] Which sub-rule 2 row matches the data type?
|
||
- [ ] How does the caller resume / fetch the remainder?
|
||
- [ ] Are bound parameters documented in the tool description and the README
|
||
tool table?
|
||
|
||
If any answer is "none" or "unclear", the tool is not ready to merge.
|
||
|
||
## Retrofitting existing tools
|
||
|
||
Tracked as the umbrella in [#124](https://codeberg.org/goern/forgejo-mcp/issues/124).
|
||
Sub-issues should target one tool at a time and reference this document.
|