Neo advertises 145+ MCP tools across six servers. To keep tools/list affordable, ToolService compacts each tool's description to 120 characters and defers the full text to get_mcp_tool_handbook, which an agent loads on demand before its first call. That design is real, switched on everywhere, and works.
It covers the tool description. It does not cover the schema shipped alongside it.
Found while reviewing PR #16583, where I drafted a description-budget Required Action on the premise that verbose property descriptions load into every agent's context. @tobiu corrected the premise — tools/list is compacted by design. Verifying that correction is what surfaced the narrower gap below, so the finding belongs to the correction, not to the PR. No live incident is attributed to this. It is a token-budget defect, filed against the design's own stated intent.
The Problem
ToolService.mjs:169-171 compacts the operation description:
Twenty lines later, :206-215 assembles what actually goes over the wire:
const toolForListing = {
name : tool.name,
title : tool.title,
description: tool.description, // compactedinputSchema: inputJsonSchema // NOT compacted, NOT projected
};
if (outputJsonSchema !== null) {
toolForListing.outputSchema = outputJsonSchema; // same
}
Every description: inside those schemas rides along in full. The path is unbroken and each link is verified:
link
evidence
OpenAPI description: → Zod .describe()
openApiValidator.mjs:259-260
Zod .describe() → JSON Schema description
measured, not assumed — z.toJSONSchema round-trip preserves the marker verbatim
JSON Schema → tools/list
ToolService.mjs:210, emitted whole
Measured
Across five servers' openapi.yaml (file-system, github-workflow, knowledge-base, memory-core, neural-link):
server
operations
operation-description bytes
schema-description bytes
schema description fields
memory-core
42
16,525
22,065
271
github-workflow
24
15,954
11,250
133
knowledge-base
13
11,978
8,662
91
neural-link
59
13,385
7,560
164
file-system
7
555
235
7
total
145
58,397
49,772
666
All six servers set compactToolDescriptions: true with toolListDescriptionMaxLength: 120, so the operation half is capped at ≤ 17,400 bytes (145 × 120, an upper bound — most are shorter). The schema half stays at 49,772 bytes, uncompacted.
So after the compaction does its job, schema descriptions are ≥ 74% of the remaining description payload. The half that was engineered away is the smaller one.
Measurement bound: five of six servers. gitlab-workflow also sets compactToolDescriptions: true and is excluded from the table, so the real total is higher, not lower.
The Architectural Reality
The asymmetry is not arbitrary — there is a genuine constraint the fix must respect.
An agent needs the input schema's shape (types, required, enums) at list time, because MCP clients validate arguments against inputSchema before dispatch. You cannot defer the schema itself the way you defer prose.
But shape and prose are separable. {"type":"boolean"} is what validation needs; the sentence explaining when to pass false is what the handbook exists for. Today they travel together because nothing splits them.
buildToolHandbookEntry (ToolService.mjs:342-360) already owns the deferred-detail surface and already receives the operation. It is the natural home for the stripped prose — no new mechanism, no second cache.
The Fix
Add a compactToolSchemas config beside compactToolDescriptions (same default-off, same per-server opt-in, so the change is observable before it is universal).
When enabled, strip description from inputSchema / outputSchema nodes in toolForListing — recursively, preserving every shape-bearing key (type, required, enum, properties, items, additionalProperties, $ref targets, format, default).
Carry the stripped prose into the existing handbook entry so get_mcp_tool_handbook returns the fully-described schema.
Extend the advertised-surface digest test to cover the projected shape, so a future edit cannot silently reintroduce the prose.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
ToolService.compactToolSchemas (new config)
this ticket
Strips schema description from tools/list; shape untouched
none. This completes an existing projection policy rather than introducing authority; no runtime contract changes shape, and the behavior is opt-in per server.
Acceptance Criteria
(Ticked with receipts by @neo-kimi-phoebe at PR #16832 cycle-2, per reviewer RA prescription — edit-trail comment below; @neo-opus-grace holds explicit revert authority.)
With compactToolSchemas: true, no annotation-positiondescription survives in a tools/listinputSchema or outputSchema served on the default listing route. (Restated from literal key-absence: an application property NAMED description under properties is data and survives — position-aware semantics. Receipt: schemaCompaction.spec.mjs real file-system contract test + the property-survival control.)
Shape is provably unchanged: a schema projected with the flag on validates exactly the same accept/reject set as with it off, asserted over a fixture carrying nested properties, items, enum, $ref and additionalProperties. (Receipt: the strip-twin equality + the Ajv accept/reject battery, 6 value shapes.)
get_mcp_tool_handbook returns the schema with descriptions for the same tool — the prose is relocated, not deleted. Proven by asserting a specific description string is absent from the list payload and present in the handbook payload. (Receipt: the real file-system contract AC-3 test. Named exception: exact-profile routes serve their constrained schema DESCRIBED by design — get_mcp_tool_handbook is policy-refused inside an exact projection, so the listing is the only surface a projected seat has for the profile's constraint prose; compaction targets the default listing only. Witness: the local-readonly-probe test.)
Default remains false; a server that does not opt in emits byte-identical tools/list output to today. (Receipt: the AC-4 default-off test, incl. the schema-free handbook entry shape.)
The advertised-surface digest test covers the projected shape, and fails if prose reappears in the list payload. (Receipt: the digest canary + the production-bound pins in schemaCompaction.spec.mjs; the identity-strip mutation reds 8 tests there. Digest-vs-listing policy distinction (cycle-3): with the flag on, the digest normalizes every route to the validation shape — exact-profile prose stays LISTED by the named exception but does not shift the capability digest: prose-only mutation stable, validation-shape mutation reds.)
Measured before/after tools/list byte counts recorded per server in the PR, using the same method as the table above. (Receipt: the PR body's six-server table, regenerated at the repair head: 187,651 → 119,141 bytes.)
Out of Scope
Editing any existing description to be shorter. The prose is not the defect; shipping it eagerly is. Individual descriptions stay as authored — several encode non-obvious safety properties (ingest_source_files's viaMcp / materializationAttempt are the current example) and shortening them would trade a measured token cost for an unmeasured comprehension cost.
Compacting the operation description further. 120 chars is working as designed.
Any change to x-neo-tool-summary, tool tiers, or the harness projection policy.
Avoided Traps
"Just drop the descriptions from the YAML." They are the input to the handbook, and openApiValidator also uses them for Zod introspection. The contract stays the SSOT; only the projection changes. Deleting at the source would fix the byte count and destroy the on-demand payload.
"Defer the whole schema like the description." Rejected: MCP clients validate arguments against inputSchema before dispatch, so the shape must be present at list time. Only prose is deferrable, which is why the fix is a recursive key-strip rather than a second lazy surface.
"Measure it later." The AC requires before/after byte counts by the stated method precisely because "this should reduce tokens" is the kind of claim that ships unverified.
Related
PR #16583 — where the premise correction that surfaced this occurred; that PR is not the cause and needs no change.
PR #16548 — established that a tool's input schema is part of the advertised-surface digest, which is why AC 5 is reachable.
#16585 — the other consumer of buildZodSchema. Independent: that one is about the contract being incomplete, this one about the projection being unfiltered.
tobiu referenced in commit 0488ab9 - "feat(ai): compactToolSchemas — schema prose leaves tools/list, relocates to the handbook (#16588) (#16832) on Aug 9, 2026, 11:14 PM
Context
Neo advertises 145+ MCP tools across six servers. To keep
tools/listaffordable,ToolServicecompacts each tool's description to 120 characters and defers the full text toget_mcp_tool_handbook, which an agent loads on demand before its first call. That design is real, switched on everywhere, and works.It covers the tool description. It does not cover the schema shipped alongside it.
Found while reviewing PR #16583, where I drafted a description-budget Required Action on the premise that verbose property descriptions load into every agent's context. @tobiu corrected the premise —
tools/listis compacted by design. Verifying that correction is what surfaced the narrower gap below, so the finding belongs to the correction, not to the PR. No live incident is attributed to this. It is a token-budget defect, filed against the design's own stated intent.The Problem
ToolService.mjs:169-171compacts the operation description:const listDescription = me.compactToolDescriptions ? me.buildToolListDescription(operation, fullDescription) // -> <= toolListDescriptionMaxLength : fullDescription;Twenty lines later,
:206-215assembles what actually goes over the wire:const toolForListing = { name : tool.name, title : tool.title, description: tool.description, // compacted inputSchema: inputJsonSchema // NOT compacted, NOT projected }; if (outputJsonSchema !== null) { toolForListing.outputSchema = outputJsonSchema; // same }Every
description:inside those schemas rides along in full. The path is unbroken and each link is verified:description:→ Zod.describe()openApiValidator.mjs:259-260.describe()→ JSON Schemadescriptionz.toJSONSchemaround-trip preserves the marker verbatimtools/listToolService.mjs:210, emitted wholeMeasured
Across five servers'
openapi.yaml(file-system,github-workflow,knowledge-base,memory-core,neural-link):memory-coregithub-workflowknowledge-baseneural-linkfile-systemAll six servers set
compactToolDescriptions: truewithtoolListDescriptionMaxLength: 120, so the operation half is capped at ≤ 17,400 bytes (145 × 120, an upper bound — most are shorter). The schema half stays at 49,772 bytes, uncompacted.So after the compaction does its job, schema descriptions are ≥ 74% of the remaining description payload. The half that was engineered away is the smaller one.
Measurement bound: five of six servers.
gitlab-workflowalso setscompactToolDescriptions: trueand is excluded from the table, so the real total is higher, not lower.The Architectural Reality
The asymmetry is not arbitrary — there is a genuine constraint the fix must respect.
An agent needs the input schema's shape (types,
required, enums) at list time, because MCP clients validate arguments againstinputSchemabefore dispatch. You cannot defer the schema itself the way you defer prose.But shape and prose are separable.
{"type":"boolean"}is what validation needs; the sentence explaining when to passfalseis what the handbook exists for. Today they travel together because nothing splits them.buildToolHandbookEntry(ToolService.mjs:342-360) already owns the deferred-detail surface and already receives the operation. It is the natural home for the stripped prose — no new mechanism, no second cache.The Fix
compactToolSchemasconfig besidecompactToolDescriptions(same default-off, same per-server opt-in, so the change is observable before it is universal).descriptionfrominputSchema/outputSchemanodes intoolForListing— recursively, preserving every shape-bearing key (type,required,enum,properties,items,additionalProperties,$reftargets,format,default).get_mcp_tool_handbookreturns the fully-described schema.Contract Ledger Matrix
ToolService.compactToolSchemas(new config)descriptionfromtools/list; shape untouchedfalse— current behaviorcompactToolDescriptionsToolService.mjs:33tools/listinputSchema/outputSchemaToolService.mjs:206-215get_mcp_tool_handbookpayloadToolService.mjs:342-360ai/mcp/ToolService.mjsADVERTISED_SURFACE_DIGEST_LABELDecision Record impact
none. This completes an existing projection policy rather than introducing authority; no runtime contract changes shape, and the behavior is opt-in per server.Acceptance Criteria
(Ticked with receipts by @neo-kimi-phoebe at PR #16832 cycle-2, per reviewer RA prescription — edit-trail comment below; @neo-opus-grace holds explicit revert authority.)
compactToolSchemas: true, no annotation-positiondescriptionsurvives in atools/listinputSchemaoroutputSchemaserved on the default listing route. (Restated from literal key-absence: an application property NAMEDdescriptionunderpropertiesis data and survives — position-aware semantics. Receipt:schemaCompaction.spec.mjsreal file-system contract test + the property-survival control.)properties,items,enum,$refandadditionalProperties. (Receipt: the strip-twin equality + the Ajv accept/reject battery, 6 value shapes.)get_mcp_tool_handbookreturns the schema with descriptions for the same tool — the prose is relocated, not deleted. Proven by asserting a specific description string is absent from the list payload and present in the handbook payload. (Receipt: the real file-system contract AC-3 test. Named exception: exact-profile routes serve their constrained schema DESCRIBED by design —get_mcp_tool_handbookis policy-refused inside an exact projection, so the listing is the only surface a projected seat has for the profile's constraint prose; compaction targets the default listing only. Witness: thelocal-readonly-probetest.)false; a server that does not opt in emits byte-identicaltools/listoutput to today. (Receipt: the AC-4 default-off test, incl. the schema-free handbook entry shape.)schemaCompaction.spec.mjs; the identity-strip mutation reds 8 tests there. Digest-vs-listing policy distinction (cycle-3): with the flag on, the digest normalizes every route to the validation shape — exact-profile prose stays LISTED by the named exception but does not shift the capability digest: prose-only mutation stable, validation-shape mutation reds.)tools/listbyte counts recorded per server in the PR, using the same method as the table above. (Receipt: the PR body's six-server table, regenerated at the repair head: 187,651 → 119,141 bytes.)Out of Scope
ingest_source_files'sviaMcp/materializationAttemptare the current example) and shortening them would trade a measured token cost for an unmeasured comprehension cost.x-neo-tool-summary, tool tiers, or the harness projection policy.Avoided Traps
"Just drop the descriptions from the YAML." They are the input to the handbook, and
openApiValidatoralso uses them for Zod introspection. The contract stays the SSOT; only the projection changes. Deleting at the source would fix the byte count and destroy the on-demand payload."Defer the whole schema like the description." Rejected: MCP clients validate arguments against
inputSchemabefore dispatch, so the shape must be present at list time. Only prose is deferrable, which is why the fix is a recursive key-strip rather than a second lazy surface."Measure it later." The AC requires before/after byte counts by the stated method precisely because "this should reduce tokens" is the kind of claim that ships unverified.
Related
buildZodSchema. Independent: that one is about the contract being incomplete, this one about the projection being unfiltered.Handoff Retrieval Hints
query_raw_memories: "tools/list compaction schema description projection handbook"ai/mcp/ToolService.mjs:169-171(compaction),:206-215(emit),:342-360(handbook entry),ai/mcp/validation/openApiValidator.mjs:259-260(description →.describe()).Origin Session ID: 8921d480-6087-4bfa-abe0-4f47873e06c4
Authored by @neo-opus-grace (Claude Opus 5).