perf(mcp): trim the one schema 58 catalogued tools share, reclaiming 2 552 tokens (#2123)
The catalog had 116 tokens of headroom and server.ts took 70 commits in the preceding 14 days, so the next ordinary tool addition would have failed CI. Measured before cutting, which is what made this findable: outputSchema is 38% of the whole catalog (23 290 tokens), and STAGED_OPERATION_SCHEMA alone accounts for 14 736 of it, the same envelope transmitted 58 times. Descriptions, which the three previous rounds trimmed, are 10%. Three edits to one constant, no tool demoted and no field removed: period_status stops declaring its three sub-properties as JSON Schema and carries them in one sentence (actor, approve and preview were already bare objects, so this makes the envelope internally consistent), the next hint drops args' redundant additionalProperties: true, and operation_id's description loses six words. Every change is in the looser direction on purpose. The server emits structuredContent for every tool and the documented failure mode is a declaration too TIGHT making a strict client reject a successful call; a looser one cannot do that. next keeps additionalProperties: false because staging.test.ts pins it as a closed shape, and a guard whose reason is not in front of you is not a guard to loosen for 420 tokens. Ceiling ratcheted 61 600 to 60 000, leaving ~1 070 tokens of deliberate working margin rather than the ~300 the previous rounds left. The bench log records the cycle that margin causes: ratchet tight, block the next feature, bump, demote. If more is needed the lever is priced: the envelope still costs ~11 800 tokens across those 58 tools, and the fix is to stop repeating it, not to trim it further. Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
b56da5d6c5
commit
8d9bf383d1
@@ -341,6 +341,40 @@ describe('tools/list payload size guard', () => {
|
||||
// Cheaper than it looks per example, so the next batch should still
|
||||
// demote a read first rather than assume there is room.
|
||||
//
|
||||
// * 61.5K to 58.9K by trimming the ONE schema 58 catalogued tools share
|
||||
// (2026-09-01). Measured first, which is what made it findable:
|
||||
// outputSchema is 38% of the whole catalog (23 290 tokens), and
|
||||
// STAGED_OPERATION_SCHEMA alone accounted for 14 736 of it, the same
|
||||
// envelope transmitted 58 times. Descriptions, which the three rounds
|
||||
// above trimmed, are only 10%.
|
||||
//
|
||||
// Three edits, no tool demoted and no field removed: period_status
|
||||
// stops declaring its sub-shape as JSON Schema and carries it in one
|
||||
// sentence instead (actor, approve and preview were already bare
|
||||
// objects, so this makes the envelope internally consistent), the next
|
||||
// hint drops args' redundant additionalProperties: true, and
|
||||
// operation_id's description loses six words. Every
|
||||
// change is in the LOOSER direction: the server emits
|
||||
// structuredContent for every tool, and the documented failure mode is
|
||||
// a declaration too TIGHT making a strict client reject a successful
|
||||
// call. A looser declaration cannot do that.
|
||||
//
|
||||
// next keeps additionalProperties: false: staging.test.ts pins it as a
|
||||
// closed shape, and a guard whose reason is not in front of you is not
|
||||
// a guard to loosen for 420 tokens.
|
||||
//
|
||||
// Ceiling set to 60 000, not to 59 250. That leaves ~1 070 tokens of
|
||||
// deliberate working margin, about two or three ordinary tool
|
||||
// additions. The log above shows the tight-ratchet cycle: ratchet to
|
||||
// ~300 margin, block the next feature, bump, feel bad, demote. server.ts
|
||||
// took 70 commits in the 14 days before this change and the margin was
|
||||
// 116 tokens, which is how that cycle starts. The margin is here so the
|
||||
// guard catches sustained growth rather than the next commit.
|
||||
//
|
||||
// If more is needed, the lever is known and priced: the envelope still
|
||||
// costs ~11 800 tokens across those 58 tools, and the structural fix is
|
||||
// to stop repeating it, not to trim it further.
|
||||
//
|
||||
// Long-term answer to growth is no longer a ceiling bump. gnubok_call_tool
|
||||
// makes `catalogVisibility: 'search'` usable for READ tools on hosts that
|
||||
// can only invoke what tools/list showed them, which is the constraint that
|
||||
@@ -350,7 +384,7 @@ describe('tools/list payload size guard', () => {
|
||||
// Only READ tools may be demoted: gnubok_call_tool refuses writes, so a
|
||||
// search-only WRITE is uncallable on Claude.ai. That is why the three
|
||||
// bumps above happened instead of demotions.
|
||||
expect(approxTokens).toBeLessThan(61_600)
|
||||
expect(approxTokens).toBeLessThan(60_000)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1461,13 +1461,20 @@ const PAGINATION_PROPS = {
|
||||
next_offset: { type: 'number', description: 'Offset for the next page (omitted on last page)' },
|
||||
} as const
|
||||
|
||||
// Declared loosely on purpose. Every field here is transmitted once per
|
||||
// staged-write tool, and there are 58 of them in the default catalog, so a
|
||||
// character in this constant costs 58 characters of every agent's context.
|
||||
// `additionalProperties: false` stays: staging.test.ts pins the next hint as a
|
||||
// closed shape, and a guard whose reason is not in front of me is not a guard
|
||||
// to loosen for 420 tokens. Only args' redundant `additionalProperties: true`
|
||||
// went, which is the JSON Schema default anyway.
|
||||
const NEXT_ACTION_HINT_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
tool: { type: 'string' },
|
||||
args: { type: 'object', additionalProperties: true },
|
||||
args: { type: 'object' },
|
||||
resource: { type: 'string' },
|
||||
},
|
||||
required: ['description'],
|
||||
@@ -1477,7 +1484,7 @@ const STAGED_OPERATION_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
staged: { type: 'boolean' },
|
||||
operation_id: { type: 'string', description: 'UUID of the staged operation, present once persisted' },
|
||||
operation_id: { type: 'string', description: 'Staged operation UUID, once persisted' },
|
||||
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
actor: { type: 'object' },
|
||||
dry_run: { type: 'boolean' },
|
||||
@@ -1485,14 +1492,13 @@ const STAGED_OPERATION_SCHEMA = {
|
||||
message: { type: 'string' },
|
||||
approve: { type: 'object' },
|
||||
preview: { type: 'object' },
|
||||
// Shape carried in prose rather than declared: the same three properties
|
||||
// spelled out as JSON Schema cost 58x what one sentence costs, and the
|
||||
// model reads the sentence either way. Same reason actor/approve/preview
|
||||
// above have always been bare objects.
|
||||
period_status: {
|
||||
type: 'object',
|
||||
description: 'Fiscal period covering the affärshändelse date. Use to detect locked/closed periods without a round-trip.',
|
||||
properties: {
|
||||
period_id: { type: ['string', 'null'] },
|
||||
status: { type: 'string', enum: ['open', 'locked', 'closed'] },
|
||||
lock_date: { type: ['string', 'null'] },
|
||||
},
|
||||
description: 'Period of the affärshändelse: period_id, status (open|locked|closed), lock_date. Detects a locked period without a round-trip.',
|
||||
},
|
||||
next: NEXT_ACTION_HINT_SCHEMA,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user