Skip to content

feat(mcp): universal JMESPath response projection (v1.4.0) + flight cabin_class fix + parity guard#3749

Merged
koala73 merged 18 commits into
mainfrom
feat/mcp-jmespath-projection
May 17, 2026
Merged

feat(mcp): universal JMESPath response projection (v1.4.0) + flight cabin_class fix + parity guard#3749
koala73 merged 18 commits into
mainfrom
feat/mcp-jmespath-projection

Conversation

@koala73

@koala73 koala73 commented May 17, 2026

Copy link
Copy Markdown
Owner

Summary

Three related changes in one PR — all live under api/mcp.ts.

1. Universal JMESPath response projection (v1.3.0v1.4.0)

Adds a single optional jmespath string 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 — omitting jmespath returns the v1.3.0 payload byte-for-byte.

  • Helper: applyJmespath(value, exprArg) → { text, failed? } — pure, never throws, returns wire-ready JSON text directly to dispatch (single JSON.stringify per request).
  • Two soft-fail gates: input gate JMESPATH_MAX_EXPR_BYTES = 1024, output gate JMESPATH_MAX_OUTPUT_BYTES = 256 KB. Both count UTF-8 bytes via TextEncoder (not text.length). Failures return {_jmespath_error, original_keys} inside the normal response envelope; the JSON-RPC layer still returns 200.
  • Stable enum: 'expression_too_long' | 'projection_too_large' | 'invalid_expression' used in BOTH failed field and _jmespath_error prefix.
  • Discovery: JMESPATH_SCHEMA injected at TOOL_LIST_RESPONSE build 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 in initialize.result.instructions (one ~600 B emit per session).
  • Edge-runtime safe: pure-JS jmespath + @types/jmespath; bundle delta +57.8 KB raw / +9.4 KB gzipped; api/mcp.ts now bundles in the same esbuild profile CI uses for api/*.js (CI loop extended via opt-in allowlist).

A/B byte savings — measured against REAL prod captures

Case Tool Unprojected (utf8) Projected (utf8) Reduction
fat get_market_data 213.6 KB 1.6 KB 99.2%
medium get_conflict_events 56.9 KB 7.1 KB 87.6%
thin get_chokepoint_status 17.4 KB 342 B 98.1%

Reproducible via npx tsx scripts/measure-jmespath-savings.mjs against checked-in tests/fixtures/jmespath-samples/*.response.json (real captures, not synthetic — each is the exact wire envelope executeTool returned at capture time). Same utf8ByteLength helper that gates the runtime output cap is used for measurement, so reported numbers match the runtime contract exactly.

2. Flight tool fix — cabin_class defaults to 'economy'

User testing surfaced search_flights returning 0 flights for many popular routes (e.g. JFK→LHR) when cabin_class is omitted, despite the tool description advertising "default economy". Root cause: _execute was only forwarding cabin_class when 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 on search_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 claims default X, asserts the _execute doesn'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.length undercounts UTF-8 bytes for non-ASCII, original measurement strategy used mcpHandler+mockDeps which doesn't intercept executeTool's direct fetch(), and a failure-kind enum name drift. Verdict: APPROVED.

5. Reviewer feedback addressed

  • P1 (lockfile): regenerated package-lock.json under Node 22 (matches CI's setup-node version); restored 5× optional-peer utf-8-validate entries dropped by Node 24's npm. npm ci --dry-run now clean.
  • P1 (CI workflow): scoped the new TS edge-bundle check to an explicit api/mcp.ts allowlist instead of recursive api/**/*.ts, which would have pulled in @vercel/og WASM-dependent endpoints.
  • P2 (synthetic fixtures): replaced with real prod captures; measurement table above shows real numbers.

Test plan

  • npm run typecheck + typecheck:api — clean
  • npm run lint on changed files — 0 errors / 0 warnings
  • npm run lint:md — 0 errors
  • npm run test:data — passes (8910/8910 on isolated runs; one pre-existing flake in tests/brief-carousel.test.mjs from loadFont'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 pass
  • Edge bundle check (api/*.js JS loop + new api/mcp.ts allowlist) — all bundle cleanly
  • npm run version:check — 2.8.0 across package.json / tauri.conf.json / Cargo.toml
  • node scripts/smoke-jmespath-edge.mjs — Tier A (jmespath in @edge-runtime/vm) + Tier B (api/mcp.ts edge bundle) both green
  • tests/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)
  • Existing tests/mcp.test.mjs — 90/90 still pass (no regression)
  • Real prod fixtures captured + measurement table above is byte-deterministic
  • Post-deploy smoke: one tools/call get_market_data with jmespath: 'data."stocks-bootstrap".quotes[*].symbol' should return a small symbol list
  • Post-deploy smoke: one search_flights JFK→LHR (no cabin_class) should return non-empty results

Notable follow-ups (out of scope here)

  • trip_duration validation gap in search_flight_prices_by_date — description says "required when is_round_trip is true" but nothing enforces it server-side
  • Handler-level fix for direct API users of /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 consumers
  • loadFont's 5s internal fetch timeout in server/_shared/brief-carousel-render.ts:48 is responsible for the tests/brief-carousel.test.mjs flake — PR fix(brief): exclude feel-good/lifestyle content from the digest pool #3748 added a 30s test wrapper but the internal fetch budget bypasses it
  • tests/fixtures/jmespath-samples/thin-get-chokepoint-status.response.json captured with stale: true and cached_at: 2026-04-05 — prod chokepoint-status data is currently stale, surfaced by this capture

koala73 added 13 commits May 17, 2026 12:06
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.
@vercel

vercel Bot commented May 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 17, 2026 4:55pm

Request Review

@greptile-apps

greptile-apps Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds three self-contained improvements to api/mcp.ts: a universal JMESPath server-side response projection (v1.4.0) injected at the dispatch boundary after existing per-tool filters, a cabin_class default fix for both flight tools, and a source-text regression guard that prevents the conditional-spread anti-pattern from reappearing on any property whose description advertises a default.

  • JMESPath projection is well-architected: two UTF-8–gated soft-fail paths, a stable error-kind enum, a module-load collision guard, and session-level discovery via initialize.result.instructions. The implementation is additive — omitting jmespath returns the v1.3.0 payload byte-for-byte.
  • Flight cabin_class fix correctly replaces the conditional-spread pattern with a nullish-coalescing default (params.cabin_class ?? 'economy') on both search_flights and search_flight_prices_by_date, restoring the advertised contract that was causing 0-result responses on popular routes.
  • Parity guard (tests/mcp-schema-default-parity.test.mjs) enforces the fix via source-text audit, with self-checks proving the regex catches the real bug shape and a positive assertion that the fix appears exactly twice.

Confidence Score: 4/5

Safe 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

Filename Overview
api/mcp.ts Adds universal JMESPath response projection, cabin_class default fix, and a module-load collision guard; implementation is well-guarded with soft-fail envelopes but has one minor identity-path gap for undefined values.
tests/mcp-jmespath.test.mjs Comprehensive 37-case unit + integration test suite covering all failure kinds, UTF-8 boundary cases, composition with summary, and backwards-compatibility guarantee.
tests/mcp-schema-default-parity.test.mjs Source-text regex audit guards against the conditional-spread anti-pattern; the split heuristic is fragile to whitespace variation, but covers the exact bug class and has self-checks.
package.json Adds jmespath runtime dep and @types/jmespath — but @types/jmespath is placed in dependencies instead of devDependencies.
scripts/capture-mcp-fixture.mjs New helper to capture real prod responses as fixtures; uses .length (UTF-16) for byte reporting in its console output, inconsistent with the UTF-8 byte counting used everywhere else in this PR.
scripts/measure-jmespath-savings.mjs Deterministic A/B measurement script reading checked-in fixtures and calling applyJmespath directly; uses utf8ByteLength correctly for all reported numbers.
.github/workflows/test.yml Extends the edge bundle check to include .ts files (excluding test files), correctly picking up api/mcp.ts in CI.
tests/fixtures/jmespath-samples/fat-get-market-data.response.json Synthetic stub fixture with SYNTHETIC marker; intentionally under-represents real prod payload size and must be replaced with a real capture before merge (open PR checkbox).

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "style(docs): add blank line before list ..." | Re-trigger Greptile

Comment thread package.json Outdated
Comment on lines 118 to 119
"@types/jmespath": "^0.15.2",
"@upstash/ratelimit": "^2.0.8",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 @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.

Suggested change
"@types/jmespath": "^0.15.2",
"@upstash/ratelimit": "^2.0.8",
"@upstash/ratelimit": "^2.0.8",

Comment thread scripts/capture-mcp-fixture.mjs Outdated
Comment on lines +97 to +98
const bytes = JSON.stringify(envelope).length;
process.stdout.write(`wrote ${target} (${bytes} bytes of compact JSON)\n`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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`);

Comment thread api/mcp.ts
Comment on lines +380 to +383
export function applyJmespath(value: unknown, exprArg: unknown): ApplyJmespathResult {
if (typeof exprArg !== 'string' || exprArg.length === 0) {
return { text: JSON.stringify(value) };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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 };
}

koala73 added 2 commits May 17, 2026 14:20
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.
koala73 added 2 commits May 17, 2026 14:41
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.
@koala73
koala73 merged commit 7ec67bd into main May 17, 2026
12 checks passed
@koala73
koala73 deleted the feat/mcp-jmespath-projection branch May 17, 2026 20:29
koala73 added a commit that referenced this pull request May 18, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant