Skip to content

feat(mcp): lazy tool schemas — descriptions up front, full schema on demand (#416) - #455

Merged
justrach merged 1 commit into
release/0.0.242from
feat/416-lazy-mcp-schemas
Aug 6, 2026
Merged

feat(mcp): lazy tool schemas — descriptions up front, full schema on demand (#416)#455
justrach merged 1 commit into
release/0.0.242from
feat/416-lazy-mcp-schemas

Conversation

@justrach

@justrach justrach commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Closes #416. The largest per-turn context win in the current batch, and the one with a measured number rather than an estimate.

The problem

mcp.zig loaded every tool schema from every connected server into the tool list up front. With #345's global always-on config that scales badly: the live evals caught one companion server silently injecting +2,568 input tokens of schemas into every session, on every call.

The measurement

Against the bundled 13-tool Smolify manifest (a real server, 9,528 bytes of description + schema, the same order as the offender above), the served catalog for that one server drops:

10,505 → 3,696 bytes, a 64.8% cut, roughly 1,700 input tokens off every request for the whole session.

Obtained by rendering schema.renderRootTools twice, once with eager = {"*"} (byte-exact pre-change behaviour) and once with the default policy, then diffing lengths. The guarding test asserts a conservative 50% so ordinary schema growth cannot turn it red.

The default: size-based, per server

≤ 4 KiB of description + serialized schema stays eager. 4 KiB is roughly 1,000 tokens; below that the load_tool_schemas round trip re-emits about as much as deferral saved, so the indirection is a wash. The consequence that matters: every small server behaves byte-for-byte as it does today, and only expensive servers change behaviour at all.

Escape hatches: GRAFF_MCP_EAGER=<names> (with * restoring the old catalog wholesale), per-server "eager": true in .mcp.json and the global config, GRAFF_MCP_SCHEMA_BUDGET, and GRAFF_MCP_LAZY_SCHEMAS=0.

Two deliberate deviations from the issue

  1. Size-based is the primary mechanism, with pins layered on, rather than a pin list alone. This is what buys zero behaviour change for the common case.
  2. load_tool_schemas is a meta tool, not an external one. External tools run on a pool thread, which would make the loaded-schema set a genuine data race against concurrent MCP calls in the same batch. Meta dispatch runs inline on the orchestrator: exactly one writer, and an immediate catalog re-render. This is why the SDKs regenerate — the diff is the single new tool name, no drift noise.

Trust and consent are untouched: trustWorkspace and every consent check are unchanged, since deferral is about context cost, not permissions.

Verification

zig build test1057/1057, exit 0 (baseline 1043), also confirmed from a clean cache dir. scripts/eval-tier1.sh green across fmt, 600-line ceiling, reachability, build, invariants and SDK sync. Test reachability proven two ways: an exact +14 count, and a deliberate break that failed as expected.

Covered by test: descriptions-only listing, with an assertion that real property names present in schemas never leak into descriptions; load_tool_schemas returning correct schemas and enabling exactly the named tools; an unloaded call producing an actionable error naming both the tool and the fix; eager pin / * / off-switch / zero-budget bypasses; per-session caching proven with a render counter; dedupe when a tool is named twice; all three provider wire formats emitting valid combinator-free placeholder schemas.

Known gaps

  • handleLoad's Agent-facing side (the invalidateRootTools/ensureRootTools re-render and the sub guard) is verified by inspection, not by test — its pure core loadInto is fully covered, but constructing a real Agent in a unit test is not set up here. load_tool_schemas was confirmed live to reach graff --schema.
  • The placeholder schema for a deferred tool is assumed acceptable to every provider. It is {"type":"object", …}, the same shape an MCP server with no inputSchema already produces, through the same serde normalization, but it was not exercised against a live provider.

src/schema.zig is at 595/600 and src/agent_tools.zig at 597/600, so the next change to either needs an extraction first.

Every connected MCP server used to put its whole `inputSchema` into the root
tool catalog at connect time, so a session paid for every schema of every
configured server on every request. Since #345 gave servers a user-level
`~/.codegraff/mcp.json` home that follows a user into every checkout, that is
a standing tax rather than a per-project choice: one companion server was
measured at +2,568 input tokens per call whether or not the model ever
touched it.

Two-phase exposure, the same progressive disclosure the `skill` tool already
uses for SKILL.md bodies:

  - Phase 1. A deferred tool is still REGISTERED - qualified name, its
    description capped to one line, and a placeholder schema - so the model
    knows the tool exists and can ask for it.
  - Phase 2. The new builtin `load_tool_schemas` returns the full JSON
    schemas for the tools (or the whole server) it is asked for and ENABLES
    them for the rest of the session. It is a meta tool, handled inline by
    the orchestrator, which both re-renders the catalog immediately and
    leaves the loaded-schema set with exactly one writer.
  - Calling a tool whose schema was never loaded is refused with the exact
    next action ("call load_tool_schemas with {tools: [...]} first"), never a
    bare unknown-tool error.

Consent is untouched, and deliberately so. Deferral is about context cost,
not permission. Nothing in the new module reads or writes approvals, and the
enforcement point sits INSIDE execToolInner - downstream of
agent_tool_gate.gateTool - so the consent prompt is still reached in exactly
the cases, and the order, it was reached in before. Deferral can only ever
subtract a call that consent already allowed; it can never add one, and it
never short-circuits or reorders an approval check.

The eager/lazy default is size-based, per server: a server whose tools cost
at most 4 KiB of description + serialized schema stays EAGER and behaves byte
for byte as it did before. 4 KiB is roughly 1,000 tokens, and below that the
load round trip re-emits about as much as deferral saved, so the indirection
only pays above it - which also means small servers keep working exactly as
today and only the expensive ones change. Pins either way:
`GRAFF_MCP_EAGER=<names>` (or `*` for the pre-#416 catalog), a per-server
`"eager": true` in .mcp.json / the global config, `GRAFF_MCP_SCHEMA_BUDGET`
to move the threshold, and `GRAFF_MCP_LAZY_SCHEMAS=0` to switch it off.
Loaded schemas are cached per session, so loading is once per session rather
than once per call.

Measured on the bundled 13-tool Smolify manifest, a real MCP server of the
same order as the one that provoked the issue: the catalog served for that
one server drops from 10,505 to 3,696 bytes - a 64.8% cut, roughly 1,700
input tokens off every request for the whole session. tool_schema_tests.zig
asserts the drop at a conservative 50%.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
@justrach
justrach changed the base branch from main to release/0.0.242 August 6, 2026 12:32
@justrach
justrach merged commit b79712d into release/0.0.242 Aug 6, 2026
6 checks passed
@justrach
justrach deleted the feat/416-lazy-mcp-schemas branch August 6, 2026 12:32
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.

MCP: lazy tool schemas — descriptions up front, full JSON schema on demand

1 participant