Skip to content

mcp: byte-budget response limiter, projection, and pagination for token efficiency - #102

Merged
tannerwendland-db merged 6 commits into
masterfrom
feat/token-limiter
Jul 24, 2026
Merged

mcp: byte-budget response limiter, projection, and pagination for token efficiency#102
tannerwendland-db merged 6 commits into
masterfrom
feat/token-limiter

Conversation

@IceRhymers

Copy link
Copy Markdown
Owner

Summary

MCP tool responses previously had no size governance: get_file could return ~250k tokens in a single call, and find_references could exceed 300k tokens at default parameters. This PR bounds every MCP response with a byte-denominated budget and strips webui-only fields from the MCP surface — without touching the shared payload builders or the webui contract (app/service.py, app/search/*, and webui/ are byte-for-byte unchanged).

What's included

  • Byte budget: CODE_SEARCH_MCP_MAX_RESPONSE_BYTES (default 100000, ≈25k tokens at ~4 bytes/token) enforced against the exact emitted JSON at the _dispatch choke-point; per-request max_bytes on all six tools, clamped down to the server ceiling with a 1024-byte floor.
  • Truncation contract: over-budget responses truncate to fit and set truncated: true / truncation_reason: "token_budget" — never a hard error. Resume handles where plumbing exists: search_code synthesizes a next_cursor (contiguous-prefix rule over the (repo_id, path, content_sha) sort order), get_file gains start_line/next_start_line line paging with byte-exact reassembly congruent with grep line numbering.
  • MCP projection: drops byte_ranges, duration_ns, and content_sha from MCP responses only (webui keeps them) — 51.1% serialized-byte reduction on a representative search_code fixture.
  • Pagination surfaced: the dormant cursor plumbing is now exposed on the search_code MCP tool (cursor param, next_cursor always present). Behavior note: row-cap fills now report truncated: false + next_cursor (matching webui semantics); garbled cursors return a structured cursor_invalid payload.
  • Telemetry: every _dispatch log line now carries pre/post-projection response byte sizes; duration_ns observability preserved via _signals().

Provenance

Built via a traced investigation (per-field token measurement across all six tools) → consensus-reviewed plan (.omc/plans/ralplan-token-limiter.md) → implementation with three independent validation reviews (functional, security, code quality). The quality review caught and fixed one real defect pre-merge: the search_code truncator lacked a progress guarantee when a single file exceeded the budget (a0d014a).

Testing

  • make test: 1138 passed (baseline was 1082; +53-test tests/unit/test_mcp_shaping.py suite plus wrapper-test migrations)
  • ruff check / ruff format --check / mypy: clean
  • Builder-identity gate: git diff --name-only master... | grep -E '^(app/service\.py|app/search/|webui/)' → no hits
  • Traversal properties pinned by tests: multi-page get_file reassembly is byte-exact (CRLF, no-trailing-newline, form-feed, U+2028/U+2029 fixtures); search_code cursor traversal recovers all content matches across truncated pages; documented edges (oversized single file/line) flagged and resumable

This pull request and its description were written by Isaac.

Byte ceiling on serialized MCP tool responses, default 100_000
(~25k tokens), following the existing CODE_SEARCH_ env-prefixed
tunable pattern. Foundation for the MCP response-shaping work;
not yet wired into app/main.py.

Co-authored-by: Isaac
…etry (Steps 2-6)

Implements the byte-budgeted MCP response pipeline entirely in app/main.py
per the consensus plan (.omc/plans/ralplan-token-limiter.md):

- project_for_mcp() drops webui-only fields (duration_ns, content_sha,
  byte_ranges) from the MCP wire payload; service.py/webui builders are
  untouched.
- _effective_budget/_fit_list/_shape_response implement the serialize ->
  measure -> tail-trim -> re-verify pipeline against the exact json.dumps
  wire string, never an estimate.
- Per-tool truncators: static tail-trim table for list_repos/
  find_references/list_imports/semantic_search; a search_code truncator
  that synthesizes next_cursor via a bounded repo name->id SELECT,
  degrading to a flagged handle-less truncation on a no-row/fault; a
  dedicated get_file line-paging shaper (start_line/next_start_line,
  split("\n") congruent with grep.py:436).
- _dispatch gains max_bytes threading, response_bytes_pre/response_bytes
  telemetry, and duration_ns/cursor_invalid in _signals().
- search_code always runs in pagination mode (cursor param, CursorError
  caught into a structured cursor_invalid payload); get_file gains
  start_line; all six tools gain max_bytes.

Migrated the two search_code wrapper tests whose fakes needed the new
cursor kwarg; added response_bytes_pre/response_bytes and
duration_ns-before-projection assertions to the dispatch log tests.
Per-tool truncator/shaping unit tests land in a follow-up commit
(tests/unit/test_mcp_shaping.py).

Co-authored-by: Isaac
New tests/unit/test_mcp_shaping.py: pure-function pins for
_effective_budget's clamp matrix, _fit_list/_fit_lines exactness,
project_for_mcp's drop-list (and None-safety on skeletal payloads),
each static truncator's flag/recompute behavior, the pinned
post-projection MCP search_code envelope shape, and the
cursor_invalid structured payload.

Fake-engine integration tests cover the search_code byte-budget
truncation path end to end: cursor-traversal losslessness for content
matches, the no-row and lookup-fault cursor-synthesis degrade paths
(never raising), and the documented mixed grep+symbol carve-out (a
page-1-only symbol match in a truncated tail is lost and flagged,
while content matches for that same file remain recoverable).

get_file gets a parametrized multi-page reassembly property test
(plain/CRLF/no-trailing-newline/form-feed/unicode-line-separator
fixtures), a line-numbering-congruence pin against split("\n") vs
splitlines(), and the single-oversized-line documented edge.

test_all_tools_respect_budget parametrizes all six tools (incl. a
200-site x 32-candidate find_references worst case) over the env
default and a small max_bytes (AC2/AC6). test_lane3_fixture_reduction
pins projection alone at >=25% serialized-byte reduction on a
synthetic 200-file/6-match/6-range search_code fixture (AC4).

Co-authored-by: Isaac
…n change

Update the MCP tools table (cursor/start_line/max_bytes params) and add
a "Response size limits" section covering CODE_SEARCH_MCP_MAX_RESPONSE_BYTES
(default 100000, ~4 bytes/token heuristic), per-request max_bytes
clamping down, the truncated/truncation_reason="token_budget" signal,
and the search_code/get_file resume handles (next_cursor/next_start_line).

Release-notes the search_code behavior change: because the MCP tool now
always runs in pagination mode, a plain row-cap fill reports
truncated=false + next_cursor instead of the old
truncated=true/truncation_reason="row_cap".

Co-authored-by: Isaac
test_all_six_tools_thread_max_bytes_to_dispatch monkeypatches
main._dispatch to capture the max_bytes kwarg each tool wrapper passes
through, closing the per-tool threading gap the plan called out
alongside test_effective_budget_clamp_matrix.

Co-authored-by: Isaac
Blocking defect from code review: when the first file's own serialized
size alone exceeded the byte budget, _fit_list returned keep=0, and the
`if keep > 0` guard around cursor synthesis meant the response came back
as `files: [], next_cursor: null, truncated: true` -- a silent,
unrecoverable dead end under default config (confirmed: one file with
2000 matches serializes past 300KB > the 100_000 default budget).

Fix mirrors get_file's existing single-oversized-line edge: floor
`keep` at 1 whenever the untruncated payload had at least one file (that
one response may still exceed the budget -- the same documented
trade-off get_file already makes), and always attempt cursor synthesis
from whatever file was kept. The safety re-serialize/shrink loop is now
floored at keep=1 rather than allowed to reach 0, so the guarantee can
never be undone there either.

Also, per review:
- Removed dangling "Principle 4"/"AC6"/"D2"/"D3" plan-doc references in
  app/main.py comments/docstrings, replaced with the actual constraint
  spelled out in words (app/main.py, README.md).
- Deduped test_signals_log_includes_duration_ns_before_projection,
  which existed verbatim in both test_main.py and test_mcp_shaping.py;
  kept the copy in test_mcp_shaping.py (its charter is the shaping
  pipeline).
- Added test_search_code_single_oversized_file_progress_guarantee: a
  single file whose own serialized size exceeds max_bytes still comes
  back alone, flagged, with a next_cursor that lets a caller traverse
  past it to the remaining files -- exercising the exact scenario the
  prior test only asserted in a comment without ever constructing.

Co-authored-by: Isaac
@tannerwendland-db
tannerwendland-db merged commit d6f5935 into master Jul 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants