feat(mcp): lazy tool schemas — descriptions up front, full schema on demand (#416) - #455
Merged
Merged
Conversation
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>
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 free
to 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.
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.zigloaded 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.renderRootToolstwice, once witheager = {"*"}(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_schemasround 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": truein.mcp.jsonand the global config,GRAFF_MCP_SCHEMA_BUDGET, andGRAFF_MCP_LAZY_SCHEMAS=0.Two deliberate deviations from the issue
load_tool_schemasis 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:
trustWorkspaceand every consent check are unchanged, since deferral is about context cost, not permissions.Verification
zig build test→ 1057/1057, exit 0 (baseline 1043), also confirmed from a clean cache dir.scripts/eval-tier1.shgreen 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_schemasreturning 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 (theinvalidateRootTools/ensureRootToolsre-render and thesubguard) is verified by inspection, not by test — its pure coreloadIntois fully covered, but constructing a real Agent in a unit test is not set up here.load_tool_schemaswas confirmed live to reachgraff --schema.{"type":"object", …}, the same shape an MCP server with noinputSchemaalready produces, through the sameserdenormalization, but it was not exercised against a live provider.src/schema.zigis at 595/600 andsrc/agent_tools.zigat 597/600, so the next change to either needs an extraction first.