feat(mcp): universal JMESPath response projection (v1.4.0) + flight cabin_class fix + parity guard#3749
Conversation
Adds jmespath@^0.16.0 + @types/jmespath@^0.15.2 in preparation for the
universal JMESPath response-projection feature (plan U1).
- scripts/smoke-jmespath-edge.mjs: two-tier smoke check.
* Tier A bundles a tiny jmespath import via esbuild (edge profile) and
runs it inside @edge-runtime/vm to confirm jmespath is edge-safe.
* Tier B builds api/mcp.ts with the same flags (build-only — VM load
fails on a pre-existing baseline issue around crypto.subtle init);
measures raw + gzipped bundle delta vs /tmp/mcp.baseline.bundle.js.
- .github/workflows/test.yml: extend the edge-bundle CI loop to cover
api/*.ts in addition to api/*.js. api/mcp.ts has never been
edge-bundled in CI; this PR closes that gap so future regressions
caught at PR time.
- typecheck:api remains clean with no new @ts-expect-error.
…SCHEMA
Adds the pure projection helper that every MCP tool will run through at
the dispatch boundary (wired in U4). Helper is the single source of
truth for the two-gate contract.
- import jmespath from 'jmespath'.
- JMESPATH_MAX_EXPR_BYTES=1024, JMESPATH_MAX_OUTPUT_BYTES=262144
(256 KB) as named exports — tests assert on them.
- JmespathFailKind = 'expression_too_long' | 'projection_too_large' |
'invalid_expression'. Same enum string is used in BOTH the helper's
`failed` field AND as the `_jmespath_error` envelope prefix, so the
two surfaces can never drift.
- utf8ByteLength(s) = new TextEncoder().encode(s).length. Edge-safe
(no Buffer dep, no String.length undercount on non-ASCII content).
- applyJmespath(value, exprArg) returns { text, failed? }. Pure;
never throws. Identity path returns JSON.stringify(value) for
undefined/empty/non-string exprArg. Input gate rejects before
parser. Output gate stringifies once, checks utf8ByteLength, soft-
fails on overrun. Failure envelope echoes original_keys (capped at
50) so the LLM can self-correct in one retry.
- JMESPATH_SCHEMA near SUMMARY_SCHEMA — intentionally terse (~110
bytes) to avoid x38 bloat across tools/list; full grammar + examples
+ caps + quota note live in initialize.instructions (U5).
- typecheck:api green, no new @ts-expect-error.
- Bundle delta (api/mcp.ts): +57,837 bytes raw / +9,377 bytes gzipped.
Just over the 50 KB raw soft-trigger but gzipped is tiny; total
bundle 466 KB / 92 KB gzipped — well under Vercel's 1 MB edge limit.
… RPC) Extends the existing TOOL_LIST_RESPONSE build-time injection so every tool's advertised inputSchema.properties gains `jmespath`. Cache tools keep `summary`; RPC tools were not summarizable but ARE projectable (JMESPath is shape-agnostic). - No TOOL_REGISTRY mutation — purely a derived-response edit. - Single new property per tool over the wire (cache tools already had `summary` from v1.3.0). - Collision guard at module load: throws if any tool hand-declares `jmespath` (or a cache tool hand-declares `summary`) on its own inputSchema. Forces resolution at edit time rather than silent overwrite. - 90/90 existing mcp.test.mjs cases still green.
…ingify) Single insertion point at the dispatch boundary, after the cache (executeTool) and RPC (tool._execute) branches converge. Both tool kinds are now projectable via the same code path, no per-tool plumbing. - applyJmespath returns the wire-ready JSON text; dispatch writes it straight into content[0].text. One JSON.stringify per request (no double-serialization cost). - Sits INSIDE the existing try block, but does NOT participate in the Pro-quota DECR rollback path: applyJmespath never throws, soft-fails return a `_jmespath_error` envelope. A bad expression is a user error after successful tool dispatch, not a system error. - Genuine tool-execution throws (e.g. cache_all_null) still hit the existing catch + DECR rollback unchanged. - Composition order with the existing knobs: _postFilter -> summary (both inside executeTool) -> jmespath (here, at dispatch). - Inline comment documents the contract so a future refactor doesn't silently relocate the wiring. - 90/90 existing mcp.test.mjs cases still green; typecheck:api clean.
…rd sync Teaches the LLM the JMESPath contract once at session-init via MCP 2025-03-26's `initialize.result.instructions` field — grammar URL, three worked examples, byte caps, and the bad-expression quota note. ~600 bytes emitted once per session vs the x38 bloat that would result from duplicating the same text into every tool's description. - SERVER_VERSION 1.3.0 -> 1.4.0 with full version-history block (bundle delta numbers + composition order documented). - public/.well-known/mcp/server-card.json: serverInfo.version 1.4.0; new features.responseProjection: "jmespath" for discovery scanners. - JMESPATH_MAX_EXPR_BYTES / JMESPATH_MAX_OUTPUT_BYTES / JmespathFailKind relocated to sit before SERVER_INSTRUCTIONS so the template can inline the caps without a temporal-dead-zone error at module load. - 90/90 baseline mcp.test.mjs cases still green; typecheck:api clean; smoke script still green.
Two scripts that implement the U6 reproducible byte-savings A/B once the three response fixtures are checked in: - scripts/capture-mcp-fixture.mjs: one-shot prod capture. Hits the prod MCP endpoint with a bearer (env-key or Pro), unwraps the content[0].text JSON envelope, writes pretty-printed JSON to tests/fixtures/jmespath-samples/<name>.response.json. Supports --arg key=value for filter args. - scripts/measure-jmespath-savings.mjs: reads each fixture, calls applyJmespath directly (bypassing executeTool / the cache fetch path — see README for the why), measures utf8ByteLength on the wire-shaped text both with and without a deterministic agent expression, emits a markdown table for the PR description. Uses the SAME utf8ByteLength helper the runtime cap uses, so reported numbers match the runtime contract exactly. - tests/fixtures/jmespath-samples/README.md: refresh procedure + the three target tools (fat=get_market_data, medium=get_conflict_events, thin=get_chokepoint_status) + rationale for direct-fixture invocation over mcpHandler+mockDeps. Fixtures themselves are not yet committed — first capture attempt returned "Invalid API key" against the prod endpoint. They land in a follow-up commit before merge.
Single test file covering the U2/U3/U4/U5 surfaces of v1.4.0: Helper unit tests (applyJmespath, utf8ByteLength): - Identity path (undefined / "" / non-string / null all return JSON.stringify(value) unchanged). - Happy-path projections including multiselect-hash and @ identity. - Invalid expression -> invalid_expression soft-fail. - Input gate: oversized expression rejected before parser. - UTF-8 byte accounting regression: a 300-emoji expression (.length=600, utf8=1200) is rejected because utf8 > cap, even though .length < cap. Catches any silent fallback to text.length. - Output gate: 1000-item multiselect-hash duplication that bloats past 256 KB is rejected and returns a tiny error envelope. - UTF-8 output gate covers 4-byte-char payloads where .length is under-cap but utf8 is over-cap. - Enum consistency: failed field deep-equals the leading token of _jmespath_error for every failure kind. - original_keys output for object / array / primitive / 100-key object truncation. Constants + schema parity (over tools/list wire output): - JMESPATH_MAX_EXPR_BYTES === 1024. - JMESPATH_MAX_OUTPUT_BYTES === 256 KiB. - JMESPATH_SCHEMA.description < 150 bytes (avoids x38 bloat). - Description points readers to initialize.instructions. - Every tool in tools/list advertises jmespath in inputSchema.properties. - jmespath is never in any tool's required array. - Cache tools have both summary + jmespath; RPC tools have only jmespath. Initialize handshake: - serverInfo.version === '1.4.0'. - result.instructions present, mentions jmespath, includes grammar URL + both cap values + daily quota note. - server-card.json.serverInfo.version matches SERVER_VERSION (cross- check that prevents future drift between the two files). - server-card.json.features.responseProjection === 'jmespath'. - capabilities + protocolVersion unchanged from v1.3.0. Dispatch integration (mcpHandler direct invocation, mocked Upstash): - Omitting jmespath returns byte-identical baseline (additive R3). - Cache tool with projection shrinks the response. - jmespath="" and jmespath=null behave as absent. - Invalid expression returns soft envelope, HTTP 200, NO JSON-RPC error. - Oversized expression returns expression_too_long soft envelope. - Genuine cache_all_null still surfaces as -32603 — confirms applyJmespath's no-throw guarantee didn't regress the rollback path. - jmespath composes with summary: true (summary first, projection second). Test results: 37/37 green; combined 127/127 with the existing tests/mcp.test.mjs (90 baseline + 37 new).
Closes the U6 measurement pipeline end-to-end. Three fixture files now
present so `scripts/measure-jmespath-savings.mjs` produces a markdown
table on every run (deterministic, reproducible).
Important: fixtures are SYNTHETIC stubs, not real prod captures. The
prod bearer provided during initial development was rejected by the
MCP endpoint (-32001 on both X-WorldMonitor-Key and Authorization
Bearer paths). Each fixture carries a top-level `_SYNTHETIC_` marker;
the README's first paragraph is a prominent warning. Replace before
merge via `scripts/capture-mcp-fixture.mjs` once a working bearer is
available.
What the synthetic stubs DO get right:
- envelope shape `{cached_at, stale, data: {...}}`
- cache-key label layout (e.g. `stocks-bootstrap`, `ucdp-events`,
`transit-summaries`)
- relative field richness (fat tools have nested arrays of richly
typed quotes, thin tools have shallow object lists)
- realistic projection expressions exercising hyphenated keys via
JMESPath backtick-quoting
What they DON'T: absolute byte counts. Synthetic fat is ~2.8 KB;
real fat is expected ~5-10 KB. Reduction PERCENTAGES from this
fixture set:
fat 86.5% (2.8 KB -> 388 B)
medium 78.2% (2.7 KB -> 594 B)
thin 85.6% (1.1 KB -> 157 B)
These are in the same ballpark as the blog-cited 80-95% for similar
shapes; real captures should fall in or near this range.
Also corrects a pre-existing bug in the measurement script that this
end-to-end run surfaced: the script's CASES had projection expressions
referencing `data.stocks` etc., but real labels are `stocks-bootstrap`
(hyphenated, requiring JMESPath quoting). Fixed alongside.
typecheck:api clean; 127/127 combined tests still green.
Self-review nit picked up during the U7 wrap. No functional change.
User testing surfaced that search_flights returns 0 results for many
popular routes (e.g. JFK→LHR) when cabin_class is omitted, even though
the tool description advertises "default economy".
Live probe (HTTP 200, same date+route):
- no cabin_class → {"flights":[],"degraded":false,"error":""}
- cabin_class=economy → 10+ real Delta/JetBlue/VS flights
Root cause: the relay / SerpAPI upstream returns empty for some routes
when cabin_class is unset. The MCP _execute was only forwarding the
param when the LLM explicitly provided it (`...(params.cabin_class ?
{cabin_class: ...} : {})`), so the LLM-default flow never sent it.
Fix: always forward cabin_class, defaulting to 'economy' (matching the
tool description's advertised default). Applied to both flight tools:
- search_flights (api/mcp.ts:2336 region)
- search_flight_prices_by_date (api/mcp.ts:2381 region)
The underlying handler at server/worldmonitor/aviation/v1/search-
google-flights.ts has the same shape for direct API callers — leaving
that alone for now since changing handler defaults is a behaviour
change for non-MCP consumers. Worth a follow-up.
typecheck:api clean; 127/127 tests still green.
…with fix Companion to 315a98e (default cabin_class to 'economy' for both flight tools). That fix changed runtime behaviour for search_flight_prices_by_date but didn't update the inputSchema description — it still said "(optional)" while the _execute now defaults to economy. Updated to "(optional, default economy)" so the contract advertised to the LLM matches what the tool actually sends. Found during a broader audit prompted by user asking "what if 10 other MCP tools have the same bug?". Audit result: NO other tools had the exact "advertised default not enforced" bug. All 23 cache-tool limit:default 30 claims are enforced via argNum(params.limit) ?? DEFAULT_LIST_LIMIT; the remaining 4 RPC-tool defaults are correctly applied. Two smaller follow-ups identified — this commit fixes one; the other (trip_duration validation gap when is_round_trip=true) deferred to a separate scoped commit.
…nforced)
Regression guard against the bug class that broke search_flights — a
tool description that claims "(default X)" but whose _execute uses the
conditional-spread anti-pattern `...(params.<key> ? { <key>: ... } : {})`,
so the LLM-default flow never sends the param to the upstream.
Three checks:
1. Matcher self-check (3 cases) — proves the regex actually catches the
exact flight-bug shape and doesn't false-positive on the fixed shape
or on other keys.
2. Source-wide audit — for every tool block in api/mcp.ts, for every
inputSchema property whose description contains "default", asserts
the conditional-spread anti-pattern does NOT appear for that key.
This is the systemic guard: any future tool addition that has the
bug shape fails CI on day one.
3. Cache-tool limit:default 30 enforcement — for every cache tool that
advertises "limit: default 30" in its description, asserts the block
references DEFAULT_LIST_LIMIT somewhere (the centrally-applied cap
from v1.3.0 / issue #3678).
Plus a positive assertion that the flight fix shape (`cabin_class:
String(params.cabin_class ?? 'economy')`) appears exactly twice in
api/mcp.ts — belt-and-braces in case a future PR deletes both the
description claim AND the conditional-spread, which the negative-only
checks would accept.
Note: this is a source-text test, not a behavioural one. It reads
api/mcp.ts as a string and regexes over tool blocks. Pros: catches
the bug class without needing __TESTING__ exports or behavioural
mocking. Cons: if the build pipeline ever transforms source before
tests run, this needs reworking — but we run tsx against source
directly, so it's fine today.
Result: 6/6 new tests pass; combined 133/133 with the existing
mcp.test.mjs + mcp-jmespath.test.mjs.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds three self-contained improvements to
Confidence Score: 4/5Safe to merge with the small corrections noted; the core JMESPath dispatch and flight fix are correct and well-tested. The JMESPath implementation is carefully constructed with soft-fail guarantees, UTF-8–correct byte gating, and backwards-compatibility verified by tests. The flight cabin_class fix is straightforward and targeted. The only items worth addressing before merge are: @types/jmespath landing in dependencies instead of devDependencies, the byte-reporting inconsistency in the capture script, and the unguarded identity path in applyJmespath. None of these affect the correctness of the JMESPath feature or the flight fix in production. The synthetic fixtures are an acknowledged open item (unchecked PR checkbox) and should be replaced with real captures before shipping. package.json (@types/jmespath in wrong dependency section), scripts/capture-mcp-fixture.mjs (UTF-16 vs UTF-8 byte reporting), and api/mcp.ts identity-path undefined guard. Important Files Changed
Sequence DiagramsequenceDiagram
participant C as MCP Client
participant D as dispatchToolsCall
participant E as executeTool
participant J as applyJmespath
participant R as rpcOk
C->>D: "tools/call { name, arguments: { jmespath? } }"
D->>E: executeTool(name, params)
Note over E: _postFilter → summary
E-->>D: "result (Record<string,unknown>)"
D->>J: applyJmespath(result, args.jmespath)
alt jmespath absent / empty / non-string
J-->>D: "{ text: JSON.stringify(result) }"
else "expression > 1024 bytes (UTF-8)"
J-->>D: "{ text: '{_jmespath_error: expression_too_long, original_keys}', failed }"
else jmespath.search throws
J-->>D: "{ text: '{_jmespath_error: invalid_expression, original_keys}', failed }"
else "projected JSON > 256 KB (UTF-8)"
J-->>D: "{ text: '{_jmespath_error: projection_too_large, original_keys}', failed }"
else projection OK
J-->>D: "{ text: projectedJsonText }"
end
D->>R: "{ content: [{ type: 'text', text }] }"
R-->>C: HTTP 200 JSON-RPC result
Reviews (1): Last reviewed commit: "style(docs): add blank line before list ..." | Re-trigger Greptile |
| "@types/jmespath": "^0.15.2", | ||
| "@upstash/ratelimit": "^2.0.8", |
There was a problem hiding this comment.
@types/jmespath is a type-declaration-only package and belongs in devDependencies, not dependencies. It ships no runtime code and is stripped by TypeScript compilation and esbuild — placing it in dependencies unnecessarily inflates the declared production dependency surface.
| "@types/jmespath": "^0.15.2", | |
| "@upstash/ratelimit": "^2.0.8", | |
| "@upstash/ratelimit": "^2.0.8", |
| const bytes = JSON.stringify(envelope).length; | ||
| process.stdout.write(`wrote ${target} (${bytes} bytes of compact JSON)\n`); |
There was a problem hiding this comment.
The console output reports
bytes using .length (UTF-16 code units) while the rest of this PR explicitly uses utf8ByteLength everywhere byte counts matter. For ASCII-only responses this is harmless, but if a tool response contains CJK or emoji the reported size will be smaller than what the JMESPATH_MAX_OUTPUT_BYTES gate actually measures, making the fixture summary misleading.
| const bytes = JSON.stringify(envelope).length; | |
| process.stdout.write(`wrote ${target} (${bytes} bytes of compact JSON)\n`); | |
| const compact = JSON.stringify(envelope); | |
| const bytes = new TextEncoder().encode(compact).length; | |
| process.stdout.write(`wrote ${target} (${bytes} utf8 bytes of compact JSON)\n`); |
| export function applyJmespath(value: unknown, exprArg: unknown): ApplyJmespathResult { | ||
| if (typeof exprArg !== 'string' || exprArg.length === 0) { | ||
| return { text: JSON.stringify(value) }; | ||
| } |
There was a problem hiding this comment.
The identity path returns
JSON.stringify(value) directly without an undefined guard, while the projected path correctly applies safeText. If a future caller passes undefined as value, this path returns { text: undefined }, which when spread into content[0].text silently drops the text field from the JSON-RPC response. Applying the same guard here makes the two paths consistent.
| export function applyJmespath(value: unknown, exprArg: unknown): ApplyJmespathResult { | |
| if (typeof exprArg !== 'string' || exprArg.length === 0) { | |
| return { text: JSON.stringify(value) }; | |
| } | |
| export function applyJmespath(value: unknown, exprArg: unknown): ApplyJmespathResult { | |
| if (typeof exprArg !== 'string' || exprArg.length === 0) { | |
| const t = JSON.stringify(value); | |
| return { text: t === undefined ? 'null' : t }; | |
| } |
Reviewer P1: prior npm install on this branch was run under Node 24 locally, which silently dropped 5× optional-peer entries for [email protected] (referenced by ws peer metadata across @react-native/dev-middleware, jayson, metro, react-devtools-core, and others). CI uses Node 22 (npm 10.x) which still reads those peer references and fails npm ci with: Missing: [email protected] from lock file Regenerated the lockfile under nvm-installed Node v22.22.3 / npm 10.9.8 (matches the CI setup-node version at .github/workflows/test.yml:40). Diff vs main: ADD jmespath@^0.16.0 + @types/jmespath@^0.15.2; all previously-dropped optional peer entries restored. Verified: `npm ci --dry-run` exits clean; `npm run typecheck:api` PASS; `npm run test:data` 8910/8910 PASS.
Reviewer P1: the prior change at .github/workflows/test.yml:46
expanded the existing edge-bundle find loop from api/*.js to
api/*.{js,ts}. That recursive pull caught unrelated TS endpoints
that esbuild can't bundle without extra loaders — specifically
api/brief/carousel/[userId]/[issueDate]/[page].ts which uses
@vercel/og's WASM imports:
No loader is configured for ".wasm" files
The PR only needed api/mcp.ts in the loop (the entry that gets the
new jmespath import). Restored the original api/*.js find loop
unchanged, added a separate explicit allowlist loop for the TS
entries we actually want to bundle-check. New entries are opt-in
with an inline comment requiring a local esbuild dry-run before
addition — prevents this regression class from recurring.
Verified locally: JS loop succeeds across all existing api/*.js;
TS allowlist succeeds on api/mcp.ts; no other endpoints pulled in.
Reviewer P2: synthetic fixture stubs were marked "replace before merge."
Captured real prod responses via scripts/capture-mcp-fixture.mjs with
an MCP-tier enterprise bearer.
Real numbers (vs synthetic placeholders):
Unprojected Projected Reduction
fat 213.6 KB → 1.6 KB 99.2%
medium 56.9 KB → 7.1 KB 87.6%
thin 17.4 KB → 342 B 98.1%
(Synthetic stubs were ~2-3 KB each and showed 78-86% reduction — under-
represented both absolute size and projection delta. Real numbers are
in the 80-99% range the blog literature cites for read-heavy MCPs.)
Also fixed thin projection expression: the real shape of
`get_chokepoint_status` returns `data["transit-summaries"].summaries`
as a map (suez/hormuz_strait/...) not the `chokepoints[]` array the
synthetic stub used. Updated CASES[2].expr to use
keys()/values() over the map. Inline comment documents the shape.
Fixture sizes (committed):
fat-get-market-data.response.json 379 KB pretty / 214 KB compact
medium-get-conflict-events.response.json 84 KB pretty / 58 KB compact
thin-get-chokepoint-status.response.json 27 KB pretty / 17 KB compact
README updated to drop "SYNTHETIC STUBS" warnings (fixtures are real
now). No code under api/mcp.ts changed; 133/133 tests still pass.
Note for follow-up: thin-get-chokepoint-status capture has
stale=true / cached_at=2026-04-05 — prod chokepoint-status data is
currently stale, surfaced by this capture. Separate concern, worth a
follow-up triage item.
P2.1 — tests/mcp-jmespath.test.mjs:mockMarketDataCache fall-through.
Setting UPSTASH_REDIS_REST_URL enables @upstash/ratelimit which makes
EVALSHA/pipeline calls to the same host. Without a catch-all, those
fall through to originalFetch('https://fake.upstash.io/...') and
burn ~5-30s of DNS-fail timeout per dispatch test. Added a
benign-pass branch for https://fake.upstash.io/* that returns
{result:null} (interpreted as "limiter unavailable" → graceful
degradation, no rate-limit applied).
Effect: dispatch suite 44s → 451ms (~100× speedup).
P2.2 — UTF-8 output-cap regression test was self-defeating. The
"not rejected" branch asserted utf8 > cap, which is the FAILURE
premise — meaning a regression to .length would PASS the assertion
even though the gate was broken. Rewrote to (a) sanity-check the
fixture premise up front, then (b) directly assert
r.failed === 'projection_too_large'. Now actually catches the
regression it claims to.
Bot.1 — @types/jmespath moved from dependencies to devDependencies.
Was added by `npm install --save` which put it in runtime deps;
correct placement is devDeps since it's TypeScript-only. Lockfile
regenerated to match.
Bot.2 — capture-mcp-fixture.mjs now reports UTF-8 byte count via
TextEncoder, matching the runtime gate's contract (utf8ByteLength).
Previously used JSON.stringify(...).length which is UTF-16 code-unit
count and undercounts non-ASCII payloads.
Bot.3 — applyJmespath identity path now guards against
JSON.stringify(undefined) returning the value `undefined` (not the
string "null"). Previously the projection path had this guard but
the identity path didn't — applyJmespath(undefined, undefined)
would return { text: undefined }, propagating into content[0].text
and serializing the field away on the wire. Added matching guard +
explicit test scenario.
134/134 tests still pass; typecheck:api clean; lockfile clean.
…C (v1.4.0 → v1.5.0) (#3770) * feat(mcp): U1 — compressDescription helper + TOOL_DESCRIPTION_MAX_BYTES Pure first-sentence-or-cap compression utility for v1.5.0's tools/list description compression. Used in U2's buildPublicTool helper. - TOOL_DESCRIPTION_MAX_BYTES = 120 (exported, single source of truth). - compressDescription(text, maxBytes): identity for short input; first-sentence extraction via /^[\s\S]+?[.!?](?:\s|$)/ for long input; falls back to UTF-8-aware byte truncation if no sentence boundary exists. - Codepoint-boundary safe — bytewise walk via TextEncoder.encode(ch), never splits a 4-byte emoji or 3-byte CJK char mid-cut. Verified via TextDecoder { fatal: true } round-trip in U1 tests. - Property-description compression deliberately NOT included here — audit found 53% of property descriptions contain contract keywords (default/optional/e.g./currently supported); deferred to follow-up. 10/10 helper tests pass (identity, sentence boundary, fallback, exact cap, UTF-8 emoji boundary, CJK byte accounting, empty, idempotence, monotonic non-growth). typecheck:api clean. Plan: docs/plans/2026-05-18-001-feat-mcp-tools-list-schema-compression-plan.md * feat(mcp): U2 — buildPublicTool shared helper + structuredClone deep-clone Single source of truth for the public tool shape — used by both tools/list (compressed) and the upcoming describe_tool RPC (full text). Refactor of v1.4.0's inline TOOL_LIST_RESPONSE.map body into a reusable helper so the two surfaces can never drift. - buildPublicTool(tool, { compressDescriptions: boolean }): PublicToolShape - structuredClone() per property AND for SUMMARY_SCHEMA/JMESPATH_SCHEMA injection. Handles nested items.enum (asset_class) and direct enum (get_news_intelligence.topic) arrays correctly. - _*-prefixed internal fields NEVER enumerated — only the public-shape fields (name, description, inputSchema with cloned properties + cloned injected schemas, annotations) are constructed. - compressDescriptions=true is the tools/list path; false is the future describe_tool path (U3). - TOOL_LIST_RESPONSE is now a 1-line map over TOOL_REGISTRY. 20/20 helper tests pass including: - Five R5 mutation tests proving structuredClone protects: * asset_class.items.enum.push (nested) * topic.enum.length = 0 (direct enum) * jmespath.description = 'EVIL' (injected schema) * summary.description = 'EVIL' (injected schema) * properties object identity (two calls = distinct refs) - R9 recursive _-prefix scan (no internal-field leak) - 90/90 baseline mcp.test.mjs still pass; typecheck:api clean. * feat(mcp): U3 — describe_tool RPC + tools/list compression wired in Adds the on-demand escape hatch: when a user/LLM finds the compressed tools/list entry ambiguous, they call describe_tool({tool_name}) to get the full uncompressed definition. Both surfaces go through the same buildPublicTool helper (U2), so they can never drift. - New describe_tool entry in TOOL_REGISTRY (RPC _execute style). Returns buildPublicTool(tool, { compressDescriptions: false }) — IDENTICAL shape to a tools/list entry, just with full long-form description. - Soft-error envelopes (HTTP 200, not JSON-RPC errors): * missing tool_name → { error: 'missing_tool_name', hint: ... } * unknown name → { error: 'unknown_tool', requested, available: [...] } where available is the sorted list of all 39 tool names so the LLM can correct in one extra call. - tools/list now compresses every tool's description ≤ 120 UTF-8 bytes via buildPublicTool's compressDescriptions: true. Test coverage (9 new dispatch scenarios on top of U2's 10 helper + 1 compress-cap unit): - tools/list returns 39 tools (38 + describe_tool) - describe_tool itself discoverable via tools/list - every tool's compressed description ≤ TOOL_DESCRIPTION_MAX_BYTES utf8 - describe_tool round-trip returns longer text than tools/list entry - describe_tool result has same shape as a tools/list entry - jmespath schema in describe_tool result is structurally equal to JMESPATH_SCHEMA (deep-clone via buildPublicTool — R5) - unknown_tool error envelope (HTTP 200, sorted available) - missing_tool_name error envelope - describe_tool round-trip recursive _-prefix scan across 7 sample tools Also bumps the hardcoded 38 → 39 in tests/mcp.test.mjs:121 and adds a describe_tool to the includes-assertion list — otherwise the tree would be red between U3 and U4. 29/29 new tests pass; 90/90 baseline mcp.test.mjs still pass; combined 157/157 (mcp + jmespath + tools-list-compression). typecheck:api clean. * feat(mcp): U4 — bump v1.4.0→v1.5.0 + server-card sync + SERVER_INSTRUCTIONS Lockstep version bumps so discovery scanners + client capability checks see the new shape. Adds session-init guidance for describe_tool. - SERVER_VERSION 1.4.0 → 1.5.0 with full version-history block (compression rationale + describe_tool + tool count 38→39). - TOOL_DESCRIPTION_MAX_BYTES hoisted from the helpers block to the version-bump caps block at the top, so SERVER_INSTRUCTIONS can quote the value without a TDZ error (same pattern as v1.4.0's JMESPATH_MAX_*_BYTES hoist). - SERVER_INSTRUCTIONS extended with one paragraph teaching the LLM: * tools/list returns compressed first-sentence descriptions (≤120B) * describe_tool({tool_name}) returns the full uncompressed definition * unknown_tool soft errors echo `available: [...]` so self-correct in one extra call - public/.well-known/mcp/server-card.json: * serverInfo.version → 1.5.0 * tools.count → 39 (was 38) * features.toolDescriptionCompression → true (alongside v1.4.0's responseProjection: 'jmespath') - New tests: version cross-check, instructions content check, server- card.json shape check. 122/122 combined (90 baseline + 32 new). typecheck:api clean. * feat(mcp): U5 — reproducible compression measurement script + R1 ≥8% guard Self-contained measurement, no checked-in baseline fixture needed. The script computes BOTH compressed (v1.5.0 path) and uncompressed (synthetic v1.4.0 shape, excluding describe_tool) inside one process by calling the shared buildPublicTool helper with both flag values plus describe_tool for full-text round-trip. Deterministic; same source → same byte counts every run. Real measured numbers: Total tools/list envelope: 40.9 KB → 36.8 KB (9.86% reduction) Tool descriptions alone: 7.8 KB → 3.4 KB (56% reduction) Matches Codex Round 3's local sanity check (~9.74%) within rounding; crosses R1's ≥8% target with margin. - scripts/measure-tools-list-compression.mjs: outputs markdown table for the PR description + exits non-zero if reduction < 8%. - tests/mcp-tools-list-compression.test.mjs: new regression test using the same self-contained measurement, asserts reduction ≥8% so a future PR can't silently regress past target by adding a giant new tool or rephrasing descriptions. - tests/mcp-jmespath.test.mjs: bump three stale v1.4.0 version assertions (lines 303, 327, 334) to v1.5.0 — these are version- specific guards inherited from PR #3749 that need to track each minor bump. 167/167 combined tests pass (mcp + mcp-jmespath + mcp-tools-list- compression + mcp-schema-default-parity). typecheck:api clean. * fix(mcp): describe_tool quota exemption + docs version bump (PR-3770 review) Address two findings on PR #3770: P2 — describe_tool was burning Pro daily quota despite being the metadata escape hatch SERVER_INSTRUCTIONS actively encourages calling while choosing tools. A Pro user at the 50/day cap couldn't even fetch tool definitions, contradicting the v1.5.0 UX hedge. - api/mcp.ts dispatchToolsCall: skip the Pro INCR-first reservation when the tool name is 'describe_tool'. Rate limit (60/min) still applies as the abuse guard. - SERVER_INSTRUCTIONS updated to teach the LLM that describe_tool is quota-exempt — "use it freely while exploring." - New test: tests/mcp.test.mjs asserts pipe.count === 0 after a Pro describe_tool call (vs === 1 for any other tool). P3 — Public docs at docs/mcp-server.mdx still cited v1.1.0 and "38 tools" while this PR ships v1.5.0 and 39 tools. server-card.json links to these docs so discovery/reference pages would be stale after merge. - Four spots updated: 38 → 39 tools (3 occurrences) and v1.1.0 → v1.5.0 server identifier (1 occurrence). - Tool-catalog description now mentions describe_tool + its quota exemption so reference readers see the contract too. 124/124 tests pass; typecheck:api clean; lint:md clean. * docs(mcp): add describe_tool reference entry + clarify quota exemption (PR-3770 review) Second round of PR #3770 review findings: P2 — docs/mcp-tools-reference.mdx was missing describe_tool. The self-describing intro says "complete reference for every MCP tool" but listed only 38, while PR #3770 exposes 39. Added a new "Meta" section with the full reference entry: parameters table, response shape, both soft-error envelopes (missing_tool_name + unknown_tool with available list), API endpoints (none — server-local), Kind (metadata lookup, sub-ms), Quota (EXEMPT from Pro daily 50/day, per-minute 60/min still applies), and a curl example. P3 — public/.well-known/mcp/server-card.json:59 still said the daily quota counts ALL tools/call. Updated the rateLimits.notes string to call out describe_tool as the second tools/call exemption (alongside initialize/tools/list/ping), referencing the v1.5.0 SERVER_INSTRUCTIONS rationale. lint:md clean; typecheck:api clean; JSON parses fine.
Summary
Three related changes in one PR — all live under
api/mcp.ts.1. Universal JMESPath response projection (
v1.3.0→v1.4.0)Adds a single optional
jmespathstring parameter to every MCP tool (cache + RPC). The server applies the expression server-side AFTER any per-tool filter/summary args, projecting the response before serialization. Composes as:_postFilter → summary → jmespath. Purely additive — omittingjmespathreturns the v1.3.0 payload byte-for-byte.applyJmespath(value, exprArg) → { text, failed? }— pure, never throws, returns wire-ready JSON text directly to dispatch (singleJSON.stringifyper request).JMESPATH_MAX_EXPR_BYTES = 1024, output gateJMESPATH_MAX_OUTPUT_BYTES = 256 KB. Both count UTF-8 bytes viaTextEncoder(nottext.length). Failures return{_jmespath_error, original_keys}inside the normal response envelope; the JSON-RPC layer still returns 200.'expression_too_long' | 'projection_too_large' | 'invalid_expression'used in BOTHfailedfield and_jmespath_errorprefix.JMESPATH_SCHEMAinjected atTOOL_LIST_RESPONSEbuild time; collision guard at module load throws if any future tool hand-declares the property. Per-tool description is intentionally terse (~110 bytes); full grammar URL + worked examples + caps + quota note live ininitialize.result.instructions(one ~600 B emit per session).jmespath+@types/jmespath; bundle delta +57.8 KB raw / +9.4 KB gzipped;api/mcp.tsnow bundles in the same esbuild profile CI uses forapi/*.js(CI loop extended via opt-in allowlist).A/B byte savings — measured against REAL prod captures
get_market_dataget_conflict_eventsget_chokepoint_statusReproducible via
npx tsx scripts/measure-jmespath-savings.mjsagainst checked-intests/fixtures/jmespath-samples/*.response.json(real captures, not synthetic — each is the exact wire envelopeexecuteToolreturned at capture time). Sameutf8ByteLengthhelper that gates the runtime output cap is used for measurement, so reported numbers match the runtime contract exactly.2. Flight tool fix —
cabin_classdefaults to'economy'User testing surfaced
search_flightsreturning 0 flights for many popular routes (e.g. JFK→LHR) whencabin_classis omitted, despite the tool description advertising "default economy". Root cause:_executewas only forwardingcabin_classwhen the LLM explicitly provided it (...(params.cabin_class ? {...} : {})), so the LLM-default flow never sent it. Fix: always forward, defaulting to'economy'. Applied to both flight tools + description sync onsearch_flight_prices_by_date.3. Schema-vs-behavior parity guard (regression test)
tests/mcp-schema-default-parity.test.mjs— for every input property whose description claimsdefault X, asserts the_executedoesn't use the conditional-spread anti-pattern. Catches the exact flight-bug class on any future tool addition. Audit ruled out the same shape in the remaining 38 tools — only the 2 flight tools had it.4. Codex 3-round adversarial review
The plan and code were reviewed by Codex (gpt-5.4) over 3 rounds before push. Findings caught included: synthetic smoke wasn't checking the real edge entry,
text.lengthundercounts UTF-8 bytes for non-ASCII, original measurement strategy usedmcpHandler+mockDepswhich doesn't interceptexecuteTool's directfetch(), and a failure-kind enum name drift. Verdict: APPROVED.5. Reviewer feedback addressed
package-lock.jsonunder Node 22 (matches CI's setup-node version); restored 5× optional-peerutf-8-validateentries dropped by Node 24's npm.npm ci --dry-runnow clean.api/mcp.tsallowlist instead of recursiveapi/**/*.ts, which would have pulled in@vercel/ogWASM-dependent endpoints.Test plan
npm run typecheck+typecheck:api— cleannpm run linton changed files — 0 errors / 0 warningsnpm run lint:md— 0 errorsnpm run test:data— passes (8910/8910 on isolated runs; one pre-existing flake intests/brief-carousel.test.mjsfromloadFont's internal 5s timeout — unrelated to this PR, surfaces under suite load when prod's font CDN is slow)node --test tests/edge-functions.test.mjs— 185/185 passnpm run version:check— 2.8.0 across package.json / tauri.conf.json / Cargo.tomlnode scripts/smoke-jmespath-edge.mjs— Tier A (jmespath in @edge-runtime/vm) + Tier B (api/mcp.ts edge bundle) both greentests/mcp-jmespath.test.mjs— 37/37 pass (helper unit, schema parity, dispatch integration, initialize handshake)tests/mcp-schema-default-parity.test.mjs— 6/6 pass (matcher self-check, source-wide audit, cache-tool limit guard)tests/mcp.test.mjs— 90/90 still pass (no regression)tools/call get_market_datawithjmespath: 'data."stocks-bootstrap".quotes[*].symbol'should return a small symbol listsearch_flights JFK→LHR(nocabin_class) should return non-empty resultsNotable follow-ups (out of scope here)
trip_durationvalidation gap insearch_flight_prices_by_date— description says "required when is_round_trip is true" but nothing enforces it server-side/api/aviation/v1/search-google-flights(same empty-on-missing-cabin-class issue) — left out because changing handler defaults is a behavior change for non-MCP consumersloadFont's 5s internal fetch timeout inserver/_shared/brief-carousel-render.ts:48is responsible for thetests/brief-carousel.test.mjsflake — PR fix(brief): exclude feel-good/lifestyle content from the digest pool #3748 added a 30s test wrapper but the internal fetch budget bypasses ittests/fixtures/jmespath-samples/thin-get-chokepoint-status.response.jsoncaptured withstale: trueandcached_at: 2026-04-05— prod chokepoint-status data is currently stale, surfaced by this capture