Skip to content

MCP tools must return minimal data by default — agents should only get their own + unread messages (umbrella for #41) #42

@MelbourneDeveloper

Description

@MelbourneDeveloper

Summary

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

statuspackages/core/src/tools/status_tool.ts:75-104

Loads and returns everything:

const agents   = (await db.listAgents()).map(agentIdentityToJson);     // ALL agents
const locks    = (await db.listLocks()).map(fileLockToJson);           // ALL locks
const plans    = (await db.listPlans()).map(agentPlanToJson);          // ALL plans
const messages = (await db.listAllMessages())                          // EVERY message row
  .filter((m) => isVisibleTo(m, agentName))                            // in-memory filter
  .map(messageToJson);                                                 // FULL bodies
return { 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-200db.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-298db.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

Proposed defaults (minimal-by-default)

  1. status → return counts (agents/locks/plans/messages totals + unread count) plus, at most, the caller's unread messages (mirror message get) and the caller's own plan/locks. Everything else behind an explicit verbose/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.)
  2. plan list → default to the caller's own plan; require agent_name or all: true (+ a limit) to widen.
  3. lock list → default to the caller's own locks; all: true (+ limit) to widen.
  4. All list-style tools → push privacy/scope filters into SQL WHERE, add a limit, never materialize the full table in memory.
  5. 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.

Relationship to #41

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions