Skip to content

Cap tool response sizes, and fix what measuring them uncovered - #32

Merged
adamjohnwright merged 1 commit into
mainfrom
fix/response-size-caps
Sep 14, 2026
Merged

adamjohnwright merged 1 commit into
mainfrom
fix/response-size-caps

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

An MCP tool result is spent from the caller's context window, and nothing here bounded one. Measured against the live services — the sizes are driven by the ID asked about, not by anything the tool decides:

tool chars ~tokens
reactome_events_hierarchy 87,996 ~22,000
reactome_query (Metabolism) 61,336 ~15,300
reactome_pathway_contained_events 23,413 ~5,900

A single call could crowd out the conversation it was meant to inform. The Cypher tool has had a total-size cap since it was written; the REST tools had none.

A cap, applied once

MAX_TOOL_RESPONSE_CHARS (40,000 — matching the Cypher default, overridable by env) is enforced in the wrapper every handler already passes through for request context. A per-tool guard is one somebody forgets to add to the fifty-seventh tool.

The cut is announced in the text. A model that cannot see it was truncated will report the partial answer as the whole one — the same class of quiet wrongness as a formatter reading the wrong field.

Root causes, not just the cap

events_hierarchy capped at three top-level pathways and then recursed every descendant of each. Now takes max_depth (default 3) and top_level_limit, and says where it stopped and which tool goes deeper. 86 KB → 16 KB.

reactome_query pretty-printed its JSON, costing ~23% in whitespace no model needs. And truncating a JSON dump returns invalid JSON while still spending the whole budget — so an object too large to return is now described: field names, shapes, sizes, and a pointer to the attribute argument.

## Metabolism
This object is 43,457 characters — too large to return whole (limit 40,000)...
**Ask for one field** with `reactome_query` and the `attribute` argument,
e.g. `{ id: "R-HSA-1430728", attribute: "hasEvent" }`.
| field | contents | size |
| `hasEvent` | array of 15 | 21,625 |

43,457 characters of severed object → 1,327 characters of usable map.

Three bugs found by checking the fix rather than assuming it

reactome_query's attribute argument had never worked. /data/query/{id}/{attribute} answers text/plain; the client asks for application/json; the service returns HTTP 406. Found only because the summary above advises using attribute — advice that would have been wrong. It now uses the text path contentClient.getText already provided.

The test harness never ran zod. fake.invoke() passed raw arguments straight to handlers, so defaults never materialised and invalid input was never rejected. A test could pass while the real server requested /data/eventsHierarchy/undefined — and no test had ever exercised the nonEmptyString validation added last week. The harness now parses through the registered schema; four tests cover validation that was previously untestable.

The sweep's "empty body" check counted lines. Compact JSON is one very long line, so a complete answer was flagged as empty. It now checks length too.

Lint caught two String(unknown) calls in my own new summariser — "[object Object]" waiting to happen.

Verification

  • 41 → 81 tests, npm run check green, coverage thresholds raised to match (53% statements)
  • Live sweep: 53 tools called, 16 content expectations checked, no suspicious output, exit 0
  • Re-measured: events_hierarchy 86 KB → 16 KB, reactome_query 60 KB → 1.3 KB on large objects

🤖 Generated with Claude Code

An MCP tool result is spent from the caller's context window, and nothing here
bounded one. Measured against the live services, the sizes are driven by the ID
asked about rather than by anything the tool decides:

  reactome_events_hierarchy   87,996 chars   ~22,000 tokens
  reactome_query (Metabolism) 61,336 chars   ~15,300 tokens
  reactome_pathway_contained_events 23,413 chars ~5,900 tokens

A single call could crowd out the conversation it was meant to inform. The
Cypher tool has had a total-size cap since it was written; the REST tools had
nothing.

**A cap, applied once.** `MAX_TOOL_RESPONSE_CHARS` (40,000, matching the Cypher
default, overridable by env) is enforced in the wrapper every handler already
passes through -- a per-tool guard is one somebody forgets to add to the
fifty-seventh tool. The cut is announced in the text: a model that cannot see it
was truncated will report the partial answer as the whole one.

**The root cause in events_hierarchy.** It capped at three top-level pathways
and then recursed every descendant of each. It now takes `max_depth` (default 3)
and `top_level_limit`, and says where it stopped and which tool goes deeper.
86 KB -> 16 KB.

**reactome_query.** Pretty-printed JSON cost ~23% in whitespace no model needs.
And truncating a JSON dump hands back invalid JSON while still spending the
whole budget, so an object too large to return is now described instead --
field names, shapes and sizes, with a pointer to the `attribute` argument.
Metabolism: 43,457 characters of severed object -> 1,327 characters of usable
map.

Three further bugs fell out of this, each found by checking the fix rather than
by assuming it:

  - **`reactome_query`'s `attribute` argument had never worked.**
    `/data/query/{id}/{attribute}` answers `text/plain`, the client asks for
    `application/json`, and the service returns HTTP 406. Found because the
    summary above advises using `attribute` -- advice that would have been
    wrong. It now uses the text path that `contentClient.getText` already had.

  - **The test harness never ran zod.** `fake.invoke()` passed raw arguments
    straight to handlers, so defaults never materialised and invalid input was
    never rejected. A test could pass while the real server requested
    `/data/eventsHierarchy/undefined`, and no test had ever exercised the
    `nonEmptyString` validation added last week. The harness now parses through
    the registered schema, and four tests cover validation that was previously
    untestable.

  - **The sweep's "empty body" check counted lines.** Compact JSON is one very
    long line, so a complete answer was reported as empty. It now checks length
    too.

Lint caught two `String(unknown)` calls in the new summariser -- "[object
Object]" waiting to happen, the same quiet wrongness as the field-path bugs.

41 -> 81 tests. Coverage thresholds raised to match. Live sweep: 53 tools
called, 16 content expectations checked, no suspicious output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 8b1fb4e into main Sep 14, 2026
4 checks passed
@adamjohnwright
adamjohnwright deleted the fix/response-size-caps branch September 14, 2026 16:29
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.

1 participant