Trim MCP tool responses to what a calling agent needs - #945
Merged
selfcontained merged 14 commits intoAug 13, 2026
Conversation
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>
Uh oh!
There was an error while loading. Please reload this page.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.brain_store_objectupdate (flippingstatus) re-transmitted the entire multi-KB object back.What changed
Two rules, applied across the MCP surface:
jsonTexthelper inapps/server/src/shared/mcp/response.ts.get_template,get_job,brain_get_object) stays the way to get everything. Each trimmed tool's description says so.list_templatespromptChars; keepspromptArgslist_jobspromptChars; also dropswebhookSecret, a credential a listing has no use forbrain_list_objects…[+N chars]brain_store_objectcollection,name,revision,updatedAt) instead of echoing the stored valuedispatch_pindispatch_list_pinsdispatch_review_list_feedbackVerification
Live against an isolated dev instance (
repo_dev_up), identical JSON-RPCtools/callrequests 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":list_templatesbrain_list_objectsbrain_store_objectdispatch_pindispatch_list_pinsAlso confirmed live:
get_templatestill returns the full prompt for a template listed without one, anddispatch_review_list_feedbackno longer carriesdiffSnapshotwhile 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