Skip to content

fix(mcp): advertise tuple inputSchema/outputSchema in a draft-07-interoperable 2020-12 projection - #580

Merged
ScriptedAlchemy merged 13 commits into
mainfrom
fix/563-interoperable-output-schema
Sep 5, 2026
Merged

fix(mcp): advertise tuple inputSchema/outputSchema in a draft-07-interoperable 2020-12 projection#580
ScriptedAlchemy merged 13 commits into
mainfrom
fix/563-interoperable-output-schema

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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/server 2.0.0), which advertises them in tools/list through schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }) and validates arguments / structuredContent through ~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 })), so prefixItems is an unknown keyword it ignores and items: false is applied to every element → MCP error -32602 … data/metrics/cargo_run_ms/buckets/0/0 boolean schema is false for every tuple member. The server's structuredContent was 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:

projection Ajv draft-07 strict:false (Cursor) Ajv draft-07 strict Ajv2020 MCP SDK 2.0 client default validator
zod draft-2020-12 (before this PR) — closed tuple FAIL boolean schema is false per element compile error (prefixItems unknown) PASS PASS
zod draft-2020-12 — rest tuple z.tuple([S]).rest(R) (items: R) FAIL data/t/0 must be number (rest schema applied to prefix positions) compile error PASS PASS
zod target: 'draft-7' (items: [A,B], additionalItems, definitions, $schema draft-07) PASS PASS compile error items must be object,boolean (or no schema with key or ref …draft-07… when $schema kept) compile error once $schema is absent (defaults to 2020-12)
this PR: post-processed 2020-12 (prefixItems kept, items → union of positional schemas, minItems/maxItems kept) PASS compile error (prefixItems unknown — no known MCP client runs Ajv strict) PASS, still rejects a wrong position or an extra element PASS

Switching the Standard Schema target to draft-7 trades 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 with prefixItems:

  • items: false (closed tuple) → items: { anyOf: [A, B] } (deduplicated; a bare schema when every position agrees) and maxItems = min(existing ?? n, n). Exact under 2020-12 (items only governs positions past prefixItems, of which maxItems allows 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.
  • items absent or true → unchanged.

Survey of everything else zod 4.5.4 emits for 2020-12 ($defs/$ref recursion, propertyNames, const, enum, numeric exclusiveMinimum, contentEncoding, type: [.., 'null'], oneOf discriminated unions, allOf pattern pairs, readOnly, deprecated, format): all pass Ajv draft-07 (strict and lax) and Ajv2020 unchanged — Ajv's core vocabulary resolves $defs in 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 in examples/* and the repo's test fixtures, none contains a tuple, so the projection is a byte-identical no-op for every shipped example; the only z.tuple in the repo (packages/workbench/src/project-client.ts) is a Workbench client schema, not MCP-advertised. The root $schema: …/draft/2020-12/schema is 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 — never const/enum/default/examples) and interoperableStandardSchema (wraps a Standard Schema so ~standard.jsonSchema.input/output return the projection while ~standard.validate stays the route schema's own; vendor: 'agent-bundle').
  • packages/agent-bundle/src/mcp-server-runtime.ts: tool inputSchema and outputSchema are registered through the wrapper (only Cursor's result-side check is observed; inputSchema has the same exposure to any host that validates arguments the same way, so it gets the same projection); advertisedOutputSchema's contract is unchanged; prompts' argsSchema is left alone (prompts advertise flat arguments, not JSON Schema); the local isRecord copy is replaced by core/strict-json.ts's export.
  • packages/agent-bundle/tests/mcp-schema-projection.test.ts (new, 53 tests): projection JSON assertions (exact tuple output, no items: false anywhere, dedupe, rest, optional tail, purity, non-schema payloads untouched, maxItems pinning); 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-hauler hauler_status metrics shape (asserting the exact data/metrics/cargo_run_ms/buckets/0/0 boolean schema is false reproduction on the unprojected schema), and a recursive $defs schema; wrapper contract; end-to-end through the real SDK server + in-memory client, including a Cursor-equivalent client (AjvJsonSchemaValidator over draft-07 Ajv) that rejects the raw registration and accepts the wrapped one.
  • Host capability tables: new mcp.structuredContentValidation row (Cursor degraded with 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, portable unavailable), rendered on the hosts reference page by website/plugins/generated-reference.ts (en + zh) as "Structured content validation by host"; the tasks details table now shares the same helper (byte-identical render).
  • Docs: website/docs/{en,zh}/guide/authoring/mcp.mdx describe the projection; docs/framework-mode.md resultSchema row updated. No new diagnostic: after the projection every shape zod can emit is interoperable, so there is nothing to warn about (docs/diagnostics.md untouched).
  • packages/agent-bundle/rslib.config.ts: mcp-schema-projection is its own rslib entry, for the same reason mcp-tasks is. Concatenated into the runtime's chunk, the new module made rslib emit dist/<runtime chunk>.js with its own __webpack_require__ runtime import and a synthesized namespace object (for agent-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:cipacked-stdio-projection.test.ts). As its own entry the runtime chunk is back to plain import { interoperableStandardSchema } from "./mcp-schema-projection.js", and the packed test passes.
  • Changeset: agent-bundle patch.

Validator matrix results (from the new test file)

fixture draft-07 lax, unprojected draft-07 lax, projected 2020-12 strict + lax, projected 2020-12 rejects wrong position / extra element draft-07 rejects element no position admits
tuple [number|null, integer≥0] FAIL boolean schema is false PASS PASS yes yes
nullable tuple element [null, 1] FAIL PASS PASS yes yes
nested tuple in object FAIL PASS PASS yes yes
array of tuples FAIL PASS PASS yes yes
rest tuple [string, ...number] FAIL must be number PASS PASS yes yes
cargo-hauler metrics (buckets, cargo_run_ms_by_kind, quantiles) FAIL data/metrics/cargo_run_ms/buckets/0/0 boolean schema is false PASS PASS yes yes
recursive $defs/$ref PASS (nothing rewritten) PASS PASS

Gates

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 lint and node scripts/run-packed-tests.mjs packages/agent-bundle/tests/packed-stdio-projection.test.ts green as well. After merging origin/main (420c66c2a, resolving the mcp.mdx en/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, and pnpm docs:site:build — all green.

Self-review

Reviewer: local generalPurpose subagent on gpt-5.6-sol-medium (the change-risk-reviewer subagent aborted because the TraceDecay daemon was down; the fallback reviewed the full origin/main...HEAD diff plus the new files and ran the focused suite). Two rounds.

Round 1 (diff at 1041868d9..e15a1f727) — 3 findings, all should-fix, all fixed in 414001146:

  1. capabilities/cursor-2026-08-28.json claimed both dialects "read the projection the same way" — draft-07 ignores prefixItems, 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 through prefixItems and draft-07 staying position-permissive.
  2. website/docs/{en,zh}/guide/authoring/mcp.mdx claimed every other emitted keyword validates identically under both dialects — a lax draft-07 validator ignores dependentRequired, unevaluatedProperties, etc. Fixed: the paragraph now says those keywords pass through untouched and may be ignored by a lax draft-07 validator.
  3. .changeset/563-interoperable-schema-projection.md stated 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, and inputSchema is 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) — 2 should-fix, 1 informational:

  1. should-fixwebsite/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 "with minItems/maxItems" implied every tuple carries both when a .rest() tuple has no maxItems. Fixed: the shared keywords are now named ($ref, propertyNames, const, numeric exclusiveMinimum), $defs is described as reached through JSON-pointer $refs under either dialect, 2020-12-only keywords (unevaluatedProperties, dependentRequired) as ignored by a lax draft-07 validator, and maxItems is qualified to closed tuples — en, zh, and framework-mode.md alike.
  2. should-fixcapabilities/portable-1.0.0.json evidence claimed the projected schema passes Ajv draft-07 "strict and strict: false"; strict mode rejects the retained prefixItems as 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 portable reason, both generated-reference.ts intros (en + zh), both mcp.mdx pages, and framework-mode.md now say "non-strict draft-07" wherever draft-07 acceptance is claimed.
  3. 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-existing mcp-tasks entry 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 in 414001146; (b) portable-1.0.0.json strict draft-07 evidence — same as round-2 finding 2, fixed in acf133b2e; (c) mcp.mdx attributed argument-side validation to Cursor while the capability row records only the result-side check — fixed: en and zh now describe the observed structuredContent check and say inputSchema goes 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-tasks and needs no exports/files/script change; projection safe for boolean subschemas, items: true, smaller existing maxItems, $ref siblings, and draft-07 items arrays; the wrapper preserves everything the SDK reads from ~standard (jsonSchema, validate, vendor) and raw zod shapes still go through registerTool's own normalization; production importer present, duplicated isRecord removed; en/zh parity — verified OK. Focused suite 53/53.

@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6e51da3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T01:45:42.619477Z 1041868 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@580
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@580
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@580
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@580

commit: 6e51da3

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment thread website/docs/en/guide/authoring/mcp.mdx Outdated
Comment on lines +67 to +68
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

ScriptedAlchemy and others added 6 commits September 5, 2026 01:49
…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)
…-output-schema

# Conflicts:
#	website/docs/en/guide/authoring/mcp.mdx
#	website/docs/zh/guide/authoring/mcp.mdx
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 5, 2026 03:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant