You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A real Claude Code session reported that 31% of its entire token budget was consumed by the too-many-cooks MCP server. MCP tool results stay in the model's context for the rest of the session, so any tool that over-returns data is a compounding tax — every redundant byte is re-read on every subsequent turn until the context is compacted.
The fix is a principle, not a one-off: every TMC tool must return the minimal useful payload by default, and full/wide views must be explicit opt-in. In particular:
An agent should, by default, only get its own messages and its unread messages. Never the whole workspace's conversation, never every lock, never every plan.
This is the umbrella issue. #41 already nails the status tool's message-blob specifically; this issue widens the mandate to all tools and pins down the default semantics.
Per-tool audit (current behaviour)
Tool
Default return today
Verdict
status
ALL agents + ALL locks (workspace) + ALL plans (workspace) + caller's entire message history (read and unread, no limit, full bodies)
🔴 worst offender
plan (list)
ALL plans for ALL agents
🔴 over-returns
lock (list)
ALL locks for ALL files
🔴 over-returns
message (get)
caller's unread messages only, filtered in SQL
✅ correct — the reference pattern
register
{ agent_name, agent_key }
✅ minimal
status — packages/core/src/tools/status_tool.ts:75-104
Loads and returns everything:
constagents=(awaitdb.listAgents()).map(agentIdentityToJson);// ALL agentsconstlocks=(awaitdb.listLocks()).map(fileLockToJson);// ALL locksconstplans=(awaitdb.listPlans()).map(agentPlanToJson);// ALL plansconstmessages=(awaitdb.listAllMessages())// EVERY message row.filter((m)=>isVisibleTo(m,agentName))// in-memory filter.map(messageToJson);// FULL bodiesreturn{content: [textContent(JSON.stringify({ agents, locks, plans, messages }))]};
The isVisibleTo predicate (status_tool.ts:55-60) filters by recipient/sender + broadcast, but does NOT filter by read state and applies NO limit. So every status call re-dumps the caller's complete conversation — read messages included — and the payload only grows over the session. This is the "dumps the entire conversation into context" behaviour. (#41 is the deep-dive on this specific blob; it reached 80,350 chars on one line in practice.)
plan list — packages/core/src/tools/plan_tool.ts:193-200 → db.listPlans() = SELECT * FROM plans (packages/too-many-cooks/src/db-sqlite.ts:823)
Returns every agent's plan in the workspace. A caller almost always wants its own plan (or a peer's by name), not all of them.
lock list — packages/core/src/tools/lock_tool.ts:295-298 → db.listLocks() = SELECT * FROM locks (db-sqlite.ts:535)
Returns every lock on every file. Grows with the codebase.
message get — the pattern to copy
packages/core/src/tools/message_tool.ts defaults unread_only to true and pushes the filter into SQL (db-sqlite.ts:679-685): unread direct messages to the caller + broadcasts the caller hasn't read (via message_reads). Bounded, scoped, correct. Every other tool should adopt this shape.
Why it matters
Tool results persist in context for the whole session → over-returning is a per-turn compounding cost, not a one-time hit.
status cost is O(total messages ever sent) and only ever climbs — a scaling cliff.
plan list → default to the caller's own plan; require agent_name or all: true (+ a limit) to widen.
lock list → default to the caller's own locks; all: true (+ limit) to widen.
All list-style tools → push privacy/scope filters into SQL WHERE, add a limit, never materialize the full table in memory.
Keep message get default unread_only: true.
Acceptance criteria
Every tool's default payload is bounded by a constant, independent of total workspace history.
By default an agent receives only its own + unread messages — assert no other agents' direct messages and no already-read messages appear in any default response.
plan/lock list default to caller-scoped; wide views require an explicit opt-in flag.
Scope/privacy filtering happens in SQL, not in-memory over SELECT *.
Regression tests: seed large N of messages/locks/plans, call each tool with defaults, assert payload length stays under a fixed ceiling and contains only caller-scoped/unread data.
Summary
A real Claude Code session reported that 31% of its entire token budget was consumed by the
too-many-cooksMCP server. MCP tool results stay in the model's context for the rest of the session, so any tool that over-returns data is a compounding tax — every redundant byte is re-read on every subsequent turn until the context is compacted.The fix is a principle, not a one-off: every TMC tool must return the minimal useful payload by default, and full/wide views must be explicit opt-in. In particular:
This is the umbrella issue. #41 already nails the
statustool's message-blob specifically; this issue widens the mandate to all tools and pins down the default semantics.Per-tool audit (current behaviour)
statusplan(list)lock(list)message(get)register{ agent_name, agent_key }status—packages/core/src/tools/status_tool.ts:75-104Loads and returns everything:
The
isVisibleTopredicate (status_tool.ts:55-60) filters by recipient/sender + broadcast, but does NOT filter by read state and applies NO limit. So everystatuscall re-dumps the caller's complete conversation — read messages included — and the payload only grows over the session. This is the "dumps the entire conversation into context" behaviour. (#41 is the deep-dive on this specific blob; it reached 80,350 chars on one line in practice.)planlist —packages/core/src/tools/plan_tool.ts:193-200→db.listPlans()=SELECT * FROM plans(packages/too-many-cooks/src/db-sqlite.ts:823)Returns every agent's plan in the workspace. A caller almost always wants its own plan (or a peer's by name), not all of them.
locklist —packages/core/src/tools/lock_tool.ts:295-298→db.listLocks()=SELECT * FROM locks(db-sqlite.ts:535)Returns every lock on every file. Grows with the codebase.
messageget — the pattern to copypackages/core/src/tools/message_tool.tsdefaultsunread_onlytotrueand pushes the filter into SQL (db-sqlite.ts:679-685): unread direct messages to the caller + broadcasts the caller hasn't read (viamessage_reads). Bounded, scoped, correct. Every other tool should adopt this shape.Why it matters
statuscost isO(total messages ever sent)and only ever climbs — a scaling cliff.statusonce history is non-trivial; it spills to disk and must be byte-sliced to read one field (see status tool returns entire message history as one unbounded single-line JSON blob (80KB+ in practice) — bound it + stop dumping full bodies #41).Proposed defaults (minimal-by-default)
status→ return counts (agents/locks/plans/messages totals + unread count) plus, at most, the caller's unread messages (mirrormessage get) and the caller's own plan/locks. Everything else behind an explicitverbose/scope: "all"opt-in. (Header-only recent slice per status tool returns entire message history as one unbounded single-line JSON blob (80KB+ in practice) — bound it + stop dumping full bodies #41 is fine; no full bodies.)planlist → default to the caller's own plan; requireagent_nameorall: true(+ alimit) to widen.locklist → default to the caller's own locks;all: true(+limit) to widen.WHERE, add alimit, never materialize the full table in memory.message getdefaultunread_only: true.Acceptance criteria
plan/locklist default to caller-scoped; wide views require an explicit opt-in flag.SELECT *.Relationship to #41
statusmessage-blob subset (bound it, drop full bodies, push the limit into the DB).