Skip to content

Trim MCP tool responses to what a calling agent needs - #945

Merged
selfcontained merged 14 commits into
mainfrom
agt_249bd474bcf7/build-mcp-response-verbosity-audit
Aug 13, 2026
Merged

Trim MCP tool responses to what a calling agent needs#945
selfcontained merged 14 commits into
mainfrom
agt_249bd474bcf7/build-mcp-response-verbosity-audit

Conversation

@selfcontained

Copy link
Copy Markdown
Owner

Why

Every byte an MCP tool returns stays in the calling agent's context for the rest of its session. Several Dispatch tools returned far more than a caller needs:

  • list_templatesoverflowed the tool-output token limit outright (108,684 chars / 218 lines in a real session) because it inlined every template's full prompt.
  • A one-field brain_store_object update (flipping status) re-transmitted the entire multi-KB object back.
  • Every JSON response was pretty-printed, paying tokens for indentation no model needs.

What changed

Two rules, applied across the MCP surface:

  1. JSON is emitted compact — 32 call sites, via a new jsonText helper in apps/server/src/shared/mcp/response.ts.
  2. List tools return a lean projection; the matching single-item tool (get_template, get_job, brain_get_object) stays the way to get everything. Each trimmed tool's description says so.
ToolChange
list_templatesprompt body → promptChars; keeps promptArgs
list_jobsprompt body → promptChars; also drops webhookSecret, a credential a listing has no use for
brain_list_objectsstrings inside listed values truncated at 400 chars, marked …[+N chars]
brain_store_objectconfirms the write (collection, name, revision, updatedAt) instead of echoing the stored value
dispatch_pinacknowledges created/updated without echoing the stored pin
dispatch_list_pinspin values truncated at 500 chars (only bites on shortcut prompts)
dispatch_review_list_feedbackdrops the per-item diff hunk — a copy of code the caller can read at the reported path

Verification

Live against an isolated dev instance (repo_dev_up), identical JSON-RPC tools/call requests before and after the change — same seeded 9.8KB template prompt and multi-KB brain object, source stashed and the API restarted to capture the "before":

ToolBeforeAfter
list_templates10,460 B536 B
brain_list_objects14,564 B1,635 B
brain_store_object14,461 B335 B
dispatch_pin292 B120 B
dispatch_list_pins303 B258 B

Also confirmed live: get_template still returns the full prompt for a template listed without one, and dispatch_review_list_feedback no longer carries diffSnapshot while keeping every other field.

Checks: pnpm run check ✅ · pnpm run test (2,682 + 798 + 60 passing) ✅ · pnpm run test:e2e (179 passing) ✅

🤖 Generated with Claude Code

selfcontainedand others added 14 commits August 12, 2026 09:10
Every byte an MCP tool returns stays in the calling agent's context for the
rest of its session. Several tools returned far more than a caller needs —
list_templates overflowed the tool-output token limit outright at 108K chars,
and a one-field brain_store_object update echoed the whole multi-KB object back.
Two rules, applied across the tool surface:
- JSON is emitted compact. Pretty-printing paid tokens for indentation no model
needs (32 call sites).
- List tools return a lean projection; the matching single-item tool
(get_template, get_job, brain_get_object) remains the way to get everything.
Per tool:
- list_templates / list_jobs: prompt bodies replaced by promptChars; templates
keep promptArgs. list_jobs also drops webhookSecret, a credential a listing
has no use for.
- brain_list_objects: strings inside listed values truncated at 400 chars, each
marked with the count dropped.
- brain_store_object: confirms the write (name, revision, timestamp) instead of
echoing the stored value.
- dispatch_pin: acknowledges created/updated without echoing the stored pin.
- dispatch_list_pins: pin values truncated at 500 chars.
- dispatch_review_list_feedback: drops the per-item diff hunk — a copy of code
the caller can read at the reported path.
Measured against a live dev instance, same requests before and after:
list_templates 10,460 → 536 bytes, brain_list_objects 14,564 → 1,635,
brain_store_object 14,461 → 335, dispatch_pin 292 → 120.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
truncateLongStrings recursed over agent-supplied JSON — a stored brain object
or pin whose nesting depth nothing on this server bounds. A deeply nested value
exhausted the call stack and failed the whole brain_list_objects request
(verified: the recursive version throws RangeError at 50k depth). An explicit
work stack has no such ceiling; the copy still preserves key order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create/update/delete on jobs, templates, and personalities echoed the whole
entity back — mostly re-transmitting the caller's own request, and carrying the
prompt it had just sent. update_template on a 9.8KB-prompt template returned
~10,400 chars; it now returns 108.
Writes confirm identity: id, name, updatedAt, plus templateId on a created job
(the auto-created backing template is the one id a caller cannot know).
get_template / get_job / list_personalities remain the way to read the record
back — including any server-applied defaults.
Same treatment for two brain writes whose payload is caller-supplied:
brain_append_event returns the assigned id and placement rather than the event
value, and brain_list_set confirms index/length/revision rather than the item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two follow-ups from the same audit.
Repo tools returned their stdout twice: RepoToolResult carries both `stdout`
and `message`, and `message` is set to `result.stdout`. Both went into the
structured payload, so every repo_dev_up/repo_dev_logs result arrived doubled.
The MCP boundary now sends `message` as the text content only, and strips it —
along with the agentId and repoRoot the caller supplied — from the structured
payload. repo_dev_status: 145 bytes of structured payload, output present once.
get_agent_history is removed. It returned 12.6KB at default args on a nearly
empty database (include_feedback/include_reviews default true), no job prompt
on this server calls it, and get_activity_summary / get_feedback_summary cover
the same ground in aggregate. Removing the tool orphaned telemetry's
getAgentHistory — 270 lines reachable only through it — so that and its tests
go too, per the dead-surface policy settled in #944. The /api/v1/history routes
behind the Activity pane are a separate path and are untouched.
tools/list drops from 70,079 to 67,776 chars.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the pattern the rest of this PR established to the tools that still
inlined their bulk, adding a detail read wherever one did not already exist.
dispatch_review_list_feedback dropped message threads, which were 71% of a
five-item listing (7,426 → 2,160 bytes), and reports messageCount instead. The
new dispatch_review_get_feedback returns one item whole — its thread and the
diff hunk the earlier commit removed from the listing, so that hunk is reachable
again rather than merely gone.
brain_query_events truncates strings inside event values like brain_list_objects
already did, and the new brain_get_event (BrainStore.getEvent) reads one back in
full. brain_list_get truncates the same way; its detail read is the offset and
limit it already accepts.
get_parent_context truncates the parent's pin values at the cap dispatch_list_pins
uses.
Left alone deliberately: persona_templates, whose instruction bodies are the
point — it exists to show an agent what a persona file looks like, so trimming
them would defeat it. list_personas and list_personalities are small enough in
absolute terms (1.3KB, 193B) that a detail tool would cost more in schema, paid
every session, than the listing costs today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: two findings on the list/detail split.
brain_list_get claimed offset/limit was the way to read an item in full, but
truncation still applied to the narrowed window, so the documented full read
could not actually return one. A window of exactly one item is now returned
untruncated — that request is the detail read, not a smaller listing — and the
description says so.
dispatch_review_get_feedback resolved its item by loading every feedback item
and thread message for every review the agent participates in. A review can
carry up to 100 items, so the detail read cost more than the listing it was
meant to relieve. New getFeedbackItemForAgent fetches the one row plus its
thread, scoped by the same ownership predicate listFeedbackItemsForAgent uses
(r.agent_id / assigned_agent_id / reviewer_agent_id), wired through as
getReviewFeedbackItem.
Verified live: an unowned item id returns "not found among this agent's
reviews", and brain_list_get with limit 1 returns an 800-char value whole where
the unbounded listing truncates it at 400.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d-mcp-response-verbosity-audit
# Conflicts:
#	apps/server/src/shared/mcp/server.ts
get_parent_context is removed. It handed a review agent its parent's pins and
media as an unscoped snapshot, and nothing asked it to: no persona file, no
review injection prompt, and no job references it — only the docs did. A
reviewer's briefing already carries what it needs. If this comes back it should
follow the rules inter-agent messaging follows rather than reading another
agent's state directly.
dispatch_list_pins takes an optional id, returning that one pin untruncated.
Verified live: a 1,200-char shortcut prompt comes back whole by id where the
listing caps it at 500. That gives dispatch_pins a real detail read to point
at — its own response already returns thin summaries (id, label, group) from
upsertPins, so it needed nothing else, and the value cap added there during the
merge with main was a no-op on data that carries no values. Reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its groups carried the full text of every top finding — 85% of the response,
growing with the corpus — when a caller reading a summary wants to know where
the patterns are, not to read five descriptions per group. Groups now report
topFindingCount alongside their counts and severity breakdown, and passing
`group` with a group's key returns that one group's findings in full.
3,012 → 457 bytes on the dev instance; the detail call returns the same 3KB for
the single group asked about. An unknown key errors rather than returning an
empty result, so a typo cannot read as "no findings".
The persona-review job doc now describes the two-step read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on the summary split.
topFindingCount counted the capped topFindings slice, so any group with five or
more distinct findings reported 5 — a group of 40 was indistinguishable from a
group of 5, while the description claimed it was the distinct count. The
aggregate now carries `distinctFindings` (descCounts.size, computed before the
slice) and the summary reports that.
The boundary also took the callback's Record<string, unknown> on trust: a result
without `groups` passed through as-is, so a malformed `{}` became an empty-looking
answer, and a null entry in the array threw during destructuring. readGroups now
validates the array and its entries, and a bad shape becomes a visible tool error.
An unknown group key also names the keys that do exist and which group_by they
belong to, so a typo — or a key from a different grouping — is recoverable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Architecture review on the PR as a whole.
`limit: 1` no longer means "untruncated". limit is a cardinality control — a
caller passing 1 is asking for a small response, so making that the one request
that returns an unbounded item inverts its intent and ties the response budget
to a pagination knob. brain_list_get truncates every page now, and the new
brain_get_list_item(collection, name, index) is the explicit read.
response.ts states the policy rather than leaving each tool to invent one: a
list returns a lean projection and there is always an explicit way to read one
entry, in exactly two sanctioned shapes — a separate single-item tool, or an
identity selector on the list tool (dispatch_list_pins `id`, get_feedback_summary
`group`) that names one entry and can mean nothing else. A cardinality control
never doubles as a detail read. One exported LIST_STRING_MAX replaces the
per-module 400 and 500 caps, so pins and brain values now truncate alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brad asked whether reading one entry is obvious for a caller. It is — but a
brain list item is addressed by position, not by an id, and removeListItem
reindexes everything after the removed item. So the two-step read this PR now
recommends had a race the other detail reads do not: pins, events, objects and
feedback items are all named by something stable.
Demonstrated on the dev instance: read a 4-item list at revision 1, note index 2
is "C", let another agent remove index 0, then read index 2 again — it silently
returns "D".
brain_get_list_item now takes an optional expectedRevision, the revision
brain_list_get already returns, and raises the store's existing revision_conflict
instead of a different item. Same optimistic-concurrency idiom the brain's write
tools use. The description says plainly that an index is a position, not an id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brad's call: reading a single list item is rare enough, and an agent that gets a
shifted item will work it out, so the optimistic-concurrency guard was more
machinery than the case warrants.
Recording why it existed, for whoever meets it later: a brain list item is
addressed by position and removeListItem reindexes everything after the removed
item, so a list-then-read pair can return a different item than the one the
caller saw — reproduced on a 4-item list, where reading index 2 after another
agent removed index 0 returned "D" where "C" had been. brain_list_get returns
the list revision, so a caller that cares can compare it itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brad's idea. whiteboard_update's description carried a ~6KB Excalidraw element
reference plus its workflow prose — 8,321 chars, 11% of the entire tools/list,
charged to every agent on every session whether or not it ever drew anything.
The guide now comes from whiteboard_howto, a 360-char tool that returns 6,807
chars on demand. whiteboard_update keeps what a caller needs to decide whether
to call it (what it does, merge semantics) and points at the howto; a failed
update appends the same pointer, so an agent that guessed at the element shape
is told where the format lives rather than left to guess again.
tools/list: 74,490 → 67,623 chars, the first cut below where this PR started.
Reviewers keep whiteboard_get and never see the authoring guide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@selfcontained
selfcontained merged commit 065af48 into mainAug 13, 2026
1 check passed
@selfcontained
selfcontained deleted the agt_249bd474bcf7/build-mcp-response-verbosity-audit branch August 13, 2026 02:18
selfcontained added a commit that referenced this pull request Aug 13, 2026
The #945/#946 wave added five tools and changed how three others are
addressed. #945 removed get_agent_history and get_parent_context from
every doc surface in the same commit that introduced
dispatch_review_get_feedback, brain_get_event, brain_get_list_item and
whiteboard_howto — so the lists looked freshly maintained while being
short by five entries.
- docs-pane Repo Tools "Built-in tools": the five new tools, plus pin
update-by-id, dispatch_pins batch writes with merge/replace, delete by
id/ids/group, and reading one pin back whole by id. A closing paragraph
states the list/detail policy response.ts now encodes, so an agent
reading the docs knows a truncated listing has a matching full read.
- dispatch_launch_agent: with a templateId the template's own prompt is
now rendered and filled from templateArgs (#941) — it was previously
used only for worktree settings, and the bullet still described it as
just "or template".
- media.tsx Pins tab: pin groups collapse under a heading with a member
count, groups over eight start collapsed, and the choice persists per
agent and group (#946).
- automations.tsx job-agent lists: the new tools, plus persona_templates
/ persona_upsert / persona_validate and dispatch_archive_agent, which
are in JOB_TOOLS but were never enumerated there.
- README interactive-agents table and the persona-agents list.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@selfcontained