perf(mcp): outputSchema was 80% of the tool payload and 54% of the context - #2529
perf(mcp): outputSchema was 80% of the tool payload and 54% of the context#2529rubenvdlinde wants to merge 3 commits into
Conversation
…ntext
Measured on the development instance, tools/list against
/apps/openregister/api/mcp:
122 tools, 433,198 bytes, ~108,299 tokens
= 54% OF A 200K CONTEXT WINDOW, re-sent every turn
One field was four fifths of it:
outputSchema 335,580 B 79.7%
inputSchema 59,022 B 14.0%
description 19,472 B 4.6%
buildOutputSchema() inlined $schema->getProperties() IN FULL for both read
verbs. Its input counterpart does the opposite -- buildInputSchema()
narrows a search verb to its DECLARED FILTERS (REQ-DERIVED-004). The
input path was designed to be economical, the output path was not, and
nothing made the difference visible. Worst case shillinq.ARInvoice.search:
36,293 B of outputSchema against 1,915 B of inputSchema -- 94% of the
tool, and more tokens by itself than hermiq's entire 22-tool set.
Now emits the ENVELOPE, not the item: search keeps {results, total,
hasMore} with results.items as a bare object; get is a bare object. The
item's properties are redundant because the model reads the actual result
when the tool returns; the envelope is not, because it stops the model
guessing whether search yields a wrapper or a bare array.
Kept the envelope rather than dropping outputSchema altogether: measured,
that would save a further 6,688 B (1.5%) and lose the shape information.
MEASURED ON THE LIVE INSTANCE AFTER DEPLOYING:
payload 411,561 B -> 97,479 B -76% (~108,300 -> ~24,400 tok)
context use 54% -> 12%
initialize 1221-1651 -> 992-1184 ms -20%
tools/list 1199-1417 -> 958-988 ms -25%
THE LATENCY DID NOT FALL PROPORTIONALLY, and the proposal says so. A 76%
smaller payload bought ~20-25% off the handshake, so the ~2 s that remains
is NOT serialisation-bound -- it is schema enumeration and descriptor
construction. This change does what it is for (context budget, and the
size of every prompt the model reads) and does NOT bring the handshake
within the 250 ms budget. Citing it as a latency fix would overstate it;
that is the caching lever, deliberately a separate change.
Also removed the now-unused Schema parameter. Not tidiness: leaving it
would tell the next reader this method legitimately depends on the
schema's properties, which is the assumption that would re-inline them.
Added a byte-budget test so a regression fails loudly. A payload
regression is otherwise invisible -- no test fails, no gate fires, nothing
errors; it surfaces as agents getting slower and dumber, which gets
blamed on the model. The test targets search+get only and says why:
create/update legitimately inline the property set into their INPUT
schema (~7.5 kB each on the fixture) and would swamp the signal.
16475 tests, 0 failures. phpcs clean.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ❌ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-16 15:04 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-17 00:58 UTC
Download the full PDF report from the workflow artifacts.
[CREW-B] Not merged — and the red cell is the ratchet inverting, not a broken testBase is The four "introduced" failures were the base being repaired, not this PRBranch point was
This PR touches What actually remains:
|
The message rendered as: Property 'ticketType' should be one of: , but is 'contactmoment'. Please choose one of the allowed values. An EMPTY allowed-list — the one fact the reader needs is the one it omits. Cause: Opis's EnumKeyword raises `$this->error($schema, $context, 'enum', 'The data should match one item from enum')` with NO fourth argument, so `$args` is empty and `$args['values']` could never be populated. The formatter was reading a key nothing ever sets. The values are taken from the schema instead. WHY IT MATTERS MORE THAN A COSMETIC MESSAGE. Measured 2026-08-17: an agent told "should be one of: , but is 'sent'" concluded the enum was empty and that NO value could be valid, and worked around a constraint that was correctly rejecting its input. I read it the same way and spent time hunting a non-existent schema bug. Both enums were fine — ticketType wants one of request|complaint|interaction, and outcome one of handled|transferred|callbackRequest|followUpAction|resolved| referred. The schema was right; the message was wrong. A self-correcting caller — an LLM deciding what to send next — needs the list. So does a human reading a 400. After: Property 'ticketType' should be one of: 'request', 'complaint', 'interaction', but is 'contactmoment'. Please choose one of the allowed values. Verified against the live instance with the same rejected payload.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-17 06:08 UTC
Download the full PDF report from the workflow artifacts.
The finding
tools/listagainst/apps/openregister/api/mcp, measured 2026-08-16:One field was four fifths of it:
outputSchemainputSchemadescriptionThe cause — an asymmetry nobody could see
buildOutputSchema()inlined$schema->getProperties()in full, for both read verbs.Its input counterpart does the opposite:
buildInputSchema()narrows asearchverb to its declared filters (REQ-DERIVED-004). The input path was designed to be economical; the output path wasn't, and nothing surfaced the difference.Worst case —
shillinq.ARInvoice.search:That one tool costs ~9,600 tokens by itself — more than hermiq's entire 22-tool set.
The change
Emit the envelope, not the item.
searchkeeps{results, total, hasMore}withresults.itemsas a bare object;getbecomes a bare object.The item's properties are redundant — the model reads the actual result when the tool returns. The envelope is not: it stops the model guessing whether
searchyields a wrapper or a bare array.Kept the envelope rather than dropping
outputSchemaoutright. Measured, that would save a further 6,688 B (1.5%) and lose the shape information.Measured live, after deploying
initializetools/listA 76% smaller payload bought only ~20–25% off the handshake — so the ~2 s that remains is not serialisation-bound. It's schema enumeration and descriptor construction.
This change does not bring the handshake within the 250 ms budget (still ~8× over). Citing it as a latency fix would overstate it. That's the caching lever, deliberately a separate change with its own invalidation question.
Two details worth a reviewer's eye
The unused
Schemaparameter is removed — not tidiness. Leaving it would tell the next reader this method legitimately depends on the schema's properties, which is exactly the assumption that would re-inline them.A byte-budget test makes a regression fail loudly. A payload regression is otherwise invisible: no test fails, no gate fires, nothing errors — it surfaces as agents getting slower and dumber, which gets blamed on the model. The test targets
search+getonly and says why in-line:create/updatelegitimately inline the property set into their input schema (~7.5 kB each) and would swamp the signal.Risk
A client validating structured content per-property loses that. Nothing in this fleet does — hermiq's runner passes tool definitions through unchanged. Called out rather than buried.
16,475 tests, 0 failures. phpcs clean.
Spec:
openspec/changes/mcp-output-schema-payload/