fix(mcp): advertise tuple inputSchema/outputSchema in a draft-07-interoperable 2020-12 projection - #580
Conversation
…roperable 2020-12 projection (#563)
🦋 Changeset detectedLatest commit: 6e51da3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1041868d98
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "state": "unavailable", | ||
| "reason": "The portable target pins no host client, so no client-side validation of arguments against `inputSchema` or of `structuredContent` against `outputSchema` is recorded. The generated server validates both against the route's zod schemas itself and advertises the interoperable 2020-12 projection — tuples as `prefixItems` plus `items` set to the union of the positional schemas, never `items: false` — so a valid result passes a draft-07 validator and a 2020-12 validator alike.", | ||
| "evidence": [ | ||
| "2026-09-05: measured with ajv 8.20.0 and zod 4.5.4 — the projected schema is accepted by Ajv draft-07 (strict and `strict: false`) and Ajv2020 for valid tuple-bearing results, while the unprojected 2020-12 output fails draft-07 with the per-element `boolean schema is false` that #563 reported; packages/agent-bundle/tests/mcp-schema-projection.test.ts pins that validator matrix." |
There was a problem hiding this comment.
Limit the evidence to non-strict draft-07
For every projected tuple, prefixItems remains in the schema, so Ajv draft-07 in its default strict mode rejects it as an unknown keyword; the commit's own validator matrix records this compile error, while the new tests exercise tuple projections only with strict: false. Because this capability evidence is rendered into the public hosts reference, claiming that both strict and non-strict draft-07 accepted it is inaccurate; limit the claim to the measured non-strict configuration.
AGENTS.md reference: AGENTS.md:L78-L81
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in acf133b (self-review round 2, finding 2): the evidence now claims only Ajv's draft-07 class in non-strict mode (strict: false, the configuration MCP clients run — strict mode rejects the retained prefixItems as an unknown keyword at compile time) plus Ajv2020 default-strict and lax; the portable reason, both hosts-page intros (en/zh), both mcp.mdx pages and docs/framework-mode.md say "non-strict draft-07" wherever draft-07 acceptance is claimed.
| arguments and `structuredContent` with draft-07 keyword semantics (Cursor, which otherwise | ||
| rejects a valid tuple with `MCP error -32602 … boolean schema is false`) accepts everything a |
There was a problem hiding this comment.
Do not attribute input validation to Cursor
In the inspected Cursor capability row, the added evidence explicitly says only result-side structuredContent validation was observed and whether Cursor validates arguments against inputSchema is unknown. This guide nevertheless identifies Cursor as a host that checks both arguments and structuredContent, turning an unverified behavior into public documentation; describe only the observed result-side check.
AGENTS.md reference: AGENTS.md:L78-L81
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 65139ae: en and zh now describe only the observed result-side check (Cursor rejecting a valid tuple result in structuredContent) and say inputSchema goes through the same projection so a host that checks arguments the same way is covered — without attributing argument-side validation to Cursor. The capability row's "not recorded" statement is unchanged.
| }, | ||
| "structuredContentValidation": { | ||
| "state": "degraded", | ||
| "reason": "Cursor validates every `tools/call` result's `structuredContent` against the advertised `outputSchema` with an Ajv-style validator that applies draft-07 keyword semantics regardless of the schema's declared 2020-12 dialect: `prefixItems` is an unknown keyword it ignores and `items: false` is applied to every element, so a zod tuple advertised as plain 2020-12 fails with `MCP error -32602: Structured content does not match the tool's output schema: … boolean schema is false` before the result reaches the model. Generated servers therefore advertise the interoperable projection for `outputSchema` and `inputSchema` alike — `prefixItems` kept, `items` rewritten to the union of the positional schemas (and of the `.rest()` schema when there is one), `minItems`/`maxItems` kept — which both dialects read the same way. Only the result-side check is observed; whether Cursor also validates arguments against `inputSchema` client-side is not recorded.", |
There was a problem hiding this comment.
Describe the dialects' different tuple semantics
The two dialects do not read this projection the same way: a 2020-12 validator applies prefixItems positionally, while Cursor's draft-07 interpretation ignores prefixItems and applies the union in items to every position. For a tuple with distinct member types, draft-07 can therefore accept swapped positions that 2020-12 rejects, so this generated public capability description overstates Cursor's validation precision; state that the projection is intentionally permissive under draft-07.
AGENTS.md reference: AGENTS.md:L78-L81
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 4140011 (self-review round 1, finding 1): the row now says both dialects accept every value the route schema accepts, with 2020-12 keeping positional precision through prefixItems and draft-07 staying position-permissive because it applies the union in items to every element.
…ning precisely (self-review)
…ime chunk stays free of rslib's __webpack_require__ (packed stdio entry failed to start)
…d-tuple-only; fix portable evidence that claimed strict draft-07 passes (self-review round 2)
…hema shares the projection (review thread)
…-output-schema # Conflicts: # website/docs/en/guide/authoring/mcp.mdx # website/docs/zh/guide/authoring/mcp.mdx
Closes #563
Root cause
registerGeneratedRoutes(packages/agent-bundle/src/mcp-server-runtime.ts) hands each tool route's zod schemas straight to the MCP TS SDK (@modelcontextprotocol/server2.0.0), which advertises them intools/listthroughschema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' })and validates arguments /structuredContentthrough~standard.validate. For a zod v4 tuple the 2020-12 output is{"type":"array","prefixItems":[A,B],"items":false,"minItems":2,"maxItems":2}— correct 2020-12. Cursor re-validates the result with an Ajv draft-07 class in non-strict mode with meta-schema validation off (the MCP SDK v1 default validator configuration:new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true })), soprefixItemsis an unknown keyword it ignores anditems: falseis applied to every element →MCP error -32602 … data/metrics/cargo_run_ms/buckets/0/0 boolean schema is falsefor every tuple member. The server'sstructuredContentwas right; the schema projection was not interoperable. Reproduced byte-for-byte locally with ajv 8.20.0 (new Ajv({ strict: false })on zod 4.5.4's output).Projection choice, with evidence
Two candidates were measured against zod 4.5.4 / ajv 8.20.0 (both already dependencies of
packages/agent-bundle) and the MCP TS SDK 2.0 client's own default validator:strict:false(Cursor)draft-2020-12(before this PR) — closed tupleboolean schema is falseper elementprefixItemsunknown)draft-2020-12— rest tuplez.tuple([S]).rest(R)(items: R)data/t/0 must be number(rest schema applied to prefix positions)target: 'draft-7'(items: [A,B],additionalItems,definitions,$schemadraft-07)items must be object,boolean(orno schema with key or ref …draft-07…when$schemakept)$schemais absent (defaults to 2020-12)prefixItemskept,items→ union of positional schemas,minItems/maxItemskept)prefixItemsunknown — no known MCP client runs Ajv strict)Switching the Standard Schema target to
draft-7trades Cursor for every 2020-12 validator, so the framework post-processes the 2020-12 output instead (packages/agent-bundle/src/mcp-schema-projection.ts). Rule, at every node withprefixItems:items: false(closed tuple) →items: { anyOf: [A, B] }(deduplicated; a bare schema when every position agrees) andmaxItems = min(existing ?? n, n). Exact under 2020-12 (itemsonly governs positions pastprefixItems, of whichmaxItemsallows none); permissive-but-passing under draft-07 (the union applies to every element).items: R(open tuple,.rest(R)) →items: { anyOf: [A, B, R] }. The one accepted precision loss: under 2020-12 rest positions may also match a prefix schema. No encoding is exact under 2020-12 and passes a draft-07 validator for rest tuples.itemsabsent ortrue→ unchanged.Survey of everything else zod 4.5.4 emits for 2020-12 (
$defs/$refrecursion,propertyNames,const,enum, numericexclusiveMinimum,contentEncoding,type: [.., 'null'],oneOfdiscriminated unions,allOfpattern pairs,readOnly,deprecated,format): all pass Ajv draft-07 (strict and lax) and Ajv2020 unchanged — Ajv's core vocabulary resolves$defsin every draft, and the remaining 2020-12-only keywords are ignored by a lax draft-07 validator rather than misapplied. The tuple encoding is the only construct that needed rewriting. Across the 155 route schemas inexamples/*and the repo's test fixtures, none contains a tuple, so the projection is a byte-identical no-op for every shipped example; the onlyz.tuplein the repo (packages/workbench/src/project-client.ts) is a Workbench client schema, not MCP-advertised. The root$schema: …/draft/2020-12/schemais kept: the projected schema is still valid 2020-12 and dialect-aware validators (the SDK 2.0 client dispatches on it) lose nothing.What changed
packages/agent-bundle/src/mcp-schema-projection.ts(new):interoperableJsonSchema(pure post-processor, recursion through schema-bearing keywords only — neverconst/enum/default/examples) andinteroperableStandardSchema(wraps a Standard Schema so~standard.jsonSchema.input/outputreturn the projection while~standard.validatestays the route schema's own;vendor: 'agent-bundle').packages/agent-bundle/src/mcp-server-runtime.ts: toolinputSchemaandoutputSchemaare registered through the wrapper (only Cursor's result-side check is observed;inputSchemahas the same exposure to any host that validates arguments the same way, so it gets the same projection);advertisedOutputSchema's contract is unchanged; prompts'argsSchemais left alone (prompts advertise flatarguments, not JSON Schema); the localisRecordcopy is replaced bycore/strict-json.ts's export.packages/agent-bundle/tests/mcp-schema-projection.test.ts(new, 53 tests): projection JSON assertions (exact tuple output, noitems: falseanywhere, dedupe, rest, optional tail, purity, non-schema payloads untouched,maxItemspinning); validator matrix — Ajv draft-07 lax (Cursor), Ajv2020 lax and strict — for tuple, nullable element, nested tuple in object, array of tuples, rest tuple, the cargo-haulerhauler_statusmetrics shape (asserting the exactdata/metrics/cargo_run_ms/buckets/0/0 boolean schema is falsereproduction on the unprojected schema), and a recursive$defsschema; wrapper contract; end-to-end through the real SDK server + in-memory client, including a Cursor-equivalent client (AjvJsonSchemaValidatorover draft-07 Ajv) that rejects the raw registration and accepts the wrapped one.mcp.structuredContentValidationrow (Cursordegradedwith the outputSchema tuples (prefixItems + items:false) fail Cursor's structured-content validation ("boolean schema is false") — emit an interoperable projection #563 evidence and the local reproduction; Claude Code, Codex, portableunavailable), rendered on the hosts reference page bywebsite/plugins/generated-reference.ts(en + zh) as "Structured content validation by host"; thetasksdetails table now shares the same helper (byte-identical render).website/docs/{en,zh}/guide/authoring/mcp.mdxdescribe the projection;docs/framework-mode.mdresultSchemarow updated. No new diagnostic: after the projection every shape zod can emit is interoperable, so there is nothing to warn about (docs/diagnostics.mduntouched).packages/agent-bundle/rslib.config.ts:mcp-schema-projectionis its own rslib entry, for the same reasonmcp-tasksis. Concatenated into the runtime's chunk, the new module made rslib emitdist/<runtime chunk>.jswith its own__webpack_require__runtime import and a synthesized namespace object (foragent-bundle/test's dynamic import of the runtime), and the generated packed stdio entry — which bundles that chunk with its own bundler runtime — failed to start with__webpack_modules__[moduleId] is not a function(check:release:ci→packed-stdio-projection.test.ts). As its own entry the runtime chunk is back to plainimport { interoperableStandardSchema } from "./mcp-schema-projection.js", and the packed test passes.agent-bundlepatch.Validator matrix results (from the new test file)
[number|null, integer≥0]boolean schema is false[null, 1][string, ...number]must be numberbuckets,cargo_run_ms_by_kind,quantiles)data/metrics/cargo_run_ms/buckets/0/0 boolean schema is false$defs/$refGates
pnpm typecheck,pnpm lint,pnpm test:unit(3643 passed),pnpm test:projection,pnpm docs:site:build(language parity checked) — all green on this branch; after the rslib entry fix,pnpm typecheck && pnpm lintandnode scripts/run-packed-tests.mjs packages/agent-bundle/tests/packed-stdio-projection.test.tsgreen as well. After mergingorigin/main(420c66c2a, resolving themcp.mdxen/zh paragraph conflict with #571 by keeping both paragraphs):pnpm install --frozen-lockfile && pnpm build,pnpm typecheck,pnpm lint,pnpm lint:release(attw + declaration imports),pnpm test:unit(3690 passed),pnpm test:projection(172 passed), the packed stdio projection test, andpnpm docs:site:build— all green.Self-review
Reviewer: local
generalPurposesubagent on gpt-5.6-sol-medium (thechange-risk-reviewersubagent aborted because the TraceDecay daemon was down; the fallback reviewed the fullorigin/main...HEADdiff plus the new files and ran the focused suite). Two rounds.Round 1 (diff at
1041868d9..e15a1f727) — 3 findings, allshould-fix, all fixed in414001146:capabilities/cursor-2026-08-28.jsonclaimed both dialects "read the projection the same way" — draft-07 ignoresprefixItems, so it accepts swapped positional values that 2020-12 rejects. Fixed: the row now says both dialects accept every value the route schema accepts, with 2020-12 keeping positional precision throughprefixItemsand draft-07 staying position-permissive.website/docs/{en,zh}/guide/authoring/mcp.mdxclaimed every other emitted keyword validates identically under both dialects — a lax draft-07 validator ignoresdependentRequired,unevaluatedProperties, etc. Fixed: the paragraph now says those keywords pass through untouched and may be ignored by a lax draft-07 validator..changeset/563-interoperable-schema-projection.mdstated 2020-12 positional precision without the.rest()loosening, and implied the Cursor argument-side failure had been observed. Fixed: precision is qualified to closed tuples with the rest-position loosening named, andinputSchemais described as advertised through the same projection without claiming an observed failure.Category checks (round 1): projection semantics, SDK integration (
tools/list, validation, tasks, harness, generated stdio entry all reach the wrapper), test coverage, locale parity, single changeset, no hand-edited generated page, production importer present — no risk found.Found by CI, not the reviewer (
check:release:ci,packed-stdio-projection.test.ts): the generated packed stdio entry failed to start with__webpack_modules__[moduleId] is not a function. Root cause and fix in "What changed" (rslib.config.ts);3caa964a1.Round 2 (diff at
3caa964a1, same reviewer model) — 2should-fix, 1informational:should-fix—website/docs/{en,zh}/guide/authoring/mcp.mdx,docs/framework-mode.md: the pass-through sentence still ended in "and so on validate the same way under both dialects", and "withminItems/maxItems" implied every tuple carries both when a.rest()tuple has nomaxItems. Fixed: the shared keywords are now named ($ref,propertyNames,const, numericexclusiveMinimum),$defsis described as reached through JSON-pointer$refs under either dialect, 2020-12-only keywords (unevaluatedProperties,dependentRequired) as ignored by a lax draft-07 validator, andmaxItemsis qualified to closed tuples — en, zh, andframework-mode.mdalike.should-fix—capabilities/portable-1.0.0.jsonevidence claimed the projected schema passes Ajv draft-07 "strict andstrict: false"; strict mode rejects the retainedprefixItemsas an unknown keyword at compile time (the PR's own matrix says so). Fixed: the evidence now says non-strict draft-07 (the configuration MCP clients run) plus Ajv2020 default-strict and lax; the portablereason, bothgenerated-reference.tsintros (en + zh), bothmcp.mdxpages, andframework-mode.mdnow say "non-strict draft-07" wherever draft-07 acceptance is claimed.informational— no test asserts directly that the built runtime chunk stays free of__webpack_require__; the packed stdio suite catches the resulting startup failure indirectly. Dismissed: that suite (check:release:ci) is exactly what caught it here, it is the same guard the pre-existingmcp-tasksentry relies on, and a direct assertion would need a build step inside the unit suite or a second packed-suite check for the same failure; both rslib entries now carry the hazard in their comment.Codex review threads (3, all P2, opened on
1041868d9/3caa964a1): (a)cursor-2026-08-28.json"both dialects read it the same way" — same as round-1 finding 1, fixed in414001146; (b)portable-1.0.0.jsonstrict draft-07 evidence — same as round-2 finding 2, fixed inacf133b2e; (c)mcp.mdxattributed argument-side validation to Cursor while the capability row records only the result-side check — fixed: en and zh now describe the observedstructuredContentcheck and sayinputSchemagoes through the same projection so a host that checks arguments the same way is covered, without claiming Cursor does. Each thread answered with its fix commit.Category checks (round 2): rslib entry placement matches
mcp-tasksand needs noexports/files/script change; projection safe for boolean subschemas,items: true, smaller existingmaxItems,$refsiblings, and draft-07itemsarrays; the wrapper preserves everything the SDK reads from~standard(jsonSchema,validate,vendor) and raw zod shapes still go throughregisterTool's own normalization; production importer present, duplicatedisRecordremoved; en/zh parity — verified OK. Focused suite 53/53.