Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/563-interoperable-schema-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Emit tuple `outputSchema`/`inputSchema` in a projection Cursor's draft-07 validator accepts (`prefixItems` + `items: anyOf` + `minItems`/`maxItems`, never `items: false`), fixing `MCP error -32602 … boolean schema is false` on tuple-bearing `tools/call` results; tool arguments are advertised through the same projection, and 2020-12 validators keep positional precision for closed tuples (a `.rest()` tuple's rest positions are loosened to the union). Host capability tables gain an `mcp.structuredContentValidation` row. (#580)
2 changes: 1 addition & 1 deletion docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ The final Agent Document of a tool route lowers to one `CallToolResult`:
| `Agent.Result metadata` | `CallToolResult._meta`. It must be a JSON object (snapshotted through the same wire boundary as `structuredContent`); anything else fails the projection closed with `McpProjectionError('invalid-result-metadata')`. Listing-level `_meta` still comes from static `config._meta`, so the MCP Apps convention stamps `_meta.ui.resourceUri` on both halves. In `config._meta.ui.resourceUri`, reference the App route instead of repeating its `ui://` literal: `appResourceUri('dashboard')` from `agent-bundle/routes` resolves at compile time to that App route's `config.resourceUri`, and a `const` string literal imported from a relative sibling module (`import { DASHBOARD_URI } from '../constants'`) is accepted too and stays available at run time for the result half. |
| `Agent.Progress` | Never a `content` block. Streamed inside a `shell` or `replace` document — normally as a `Suspense` fallback — it projects to one `notifications/progress` (`progress` from `completed`, plus `message` and `total` when present) when the request carried `_meta.progressToken`; a request without a token gets none. The same monotonic rule applies as to `progress.report()`: each notification's `progress` must exceed the last, so a fallback re-streamed on the next chunk, or one an explicit report already announced with the same `completed`, is not repeated. A fallback alone is enough — an `announce()`-style helper that repeats the fallback message through `progress.report()` adds nothing (#448). A progress node in the final document is content only. The rendered CLI's interactive TTY draws its in-place progress line from the same streamed node (redrawn only when the fallback changes); piped Markdown, `--json`, and `--ndjson` never print it. |
| `Agent.Error code message` | `isError: true` plus one text block `[<code>] <message>`. The wire has no error-code field, so the code is deliberately kept in the text (the routed CLI prints the same `**[code]** message` form); choose codes that read well to the model. |
| `resultSchema` | `outputSchema` in `tools/list` **only when the schema describes an object** (`z.object`, `z.record`, a discriminated union of objects). The MCP specification requires every result of a tool that declares `outputSchema` to carry `structuredContent`, so a text-only route declares `resultSchema = z.undefined()` (or any non-object schema), advertises no `outputSchema`, and returns no `structuredContent`. An object schema keeps the SDK's fail-closed output validation on every call. |
| `resultSchema` | `outputSchema` in `tools/list` **only when the schema describes an object** (`z.object`, `z.record`, a discriminated union of objects). The MCP specification requires every result of a tool that declares `outputSchema` to carry `structuredContent`, so a text-only route declares `resultSchema = z.undefined()` (or any non-object schema), advertises no `outputSchema`, and returns no `structuredContent`. An object schema keeps the SDK's fail-closed output validation on every call. Both `inputSchema` and `outputSchema` are advertised through the interoperable 2020-12 projection (`src/mcp-schema-projection.ts`, #563): a zod tuple is emitted as `prefixItems` plus `items` set to the union of the positional schemas with `minItems` (and `maxItems` for a closed tuple), never `items: false`, so a host that validates with non-strict draft-07 keyword semantics (Cursor) accepts every result a 2020-12 validator accepts. |

### What happens when a route throws

Expand Down
7 changes: 7 additions & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export default defineConfig({
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
meta: './src/meta.ts',
// Same reason as `mcp-tasks` below: the runtime's only other private
// sibling. Concatenated into the runtime's chunk it makes that chunk
// host two modules, so rslib synthesizes the runtime's namespace
// object (for `agent-bundle/test`'s dynamic import) through its own
// `__webpack_require__`, and the generated stdio entry fails to start
// with `__webpack_modules__[moduleId] is not a function`.
'mcp-schema-projection': './src/mcp-schema-projection.ts',
'mcp-server-runtime': './src/mcp-server-runtime.ts',
// Its own entry so it is emitted as a chunk beside the runtime rather
// than concatenated into it: a generated artifact bundles
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,13 @@
"retrieved 2026-09-04 from https://code.claude.com/docs/en/mcp: the v2 runtime (SDK 2.0, v2.1.232+) asks stdio servers for protocol revision 2026-07-28 only when `MCP_PROTOCOL_NEGOTIATION=auto`; otherwise stdio servers negotiate through the earlier `initialize` handshake, where the generated server serves the 2025-11-25 core Tasks utility to a client that sends `params.task`.",
"live model 2026-09-03, Claude Code 2.1.257 (host-lineage audit): every recorded tools/call carried `_meta.claudecode/toolUseId` and a progressToken; none carried `params.task`."
]
},
"structuredContentValidation": {
"state": "unavailable",
"reason": "Claude Code is not recorded as validating `structuredContent` against `outputSchema`, or arguments against `inputSchema`, client-side: the tuple-bearing result that Cursor's draft-07 validator rejects was accepted by Claude Code. The generated server's SDK-side validation of arguments and `structuredContent` against the route's zod schemas is the only check recorded, and the same interoperable 2020-12 projection is advertised to Claude Code unchanged.",
"evidence": [
"2026-09-04 (#563): the same tuple-bearing `hauler_status` result (`z.array(z.tuple([z.number().nullable(), z.number().int().nonnegative()]))`) that Cursor 3.18.25 rejected with `boolean schema is false` was accepted by Claude Code; the report records Claude Code and Codex as not validating `outputSchema` that way."
]
}
},
"noticeDelivery": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,13 @@
"Codex CLI 0.147.0 release (2026-08-07): MCP 2026-07-28 support is opt-in through the `mcp_2026_07_28` feature flag or `codex --enable mcp_2026_07_28`, adding paginated discovery, multi-round (`input_required`) requests, and non-blocking server startup; task-augmented tool calls are not among the documented additions.",
"2026-09-03: recorded tools/call requests carry `_meta.x-codex-turn-metadata` (thread_id, turn_id, session_id) and no `params.task`."
]
},
"structuredContentValidation": {
"state": "unavailable",
"reason": "Codex is not recorded as validating `structuredContent` against `outputSchema`, or arguments against `inputSchema`, client-side: the tuple-bearing result that Cursor's draft-07 validator rejects was accepted by Codex. The generated server's SDK-side validation of arguments and `structuredContent` against the route's zod schemas is the only check recorded, and the same interoperable 2020-12 projection is advertised to Codex unchanged.",
"evidence": [
"2026-09-04 (#563): the same tuple-bearing `hauler_status` result (`z.array(z.tuple([z.number().nullable(), z.number().int().nonnegative()]))`) that Cursor 3.18.25 rejected with `boolean schema is false` was accepted by Codex; the report records Codex and Claude Code as not validating `outputSchema` that way."
]
}
},
"noticeDelivery": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,14 @@
"retrieved 2026-09-04 from https://cursor.com/docs/context/mcp, \"Protocol and extension support\": Tools, Prompts, Resources, Roots, Elicitation, Apps (extension) are the supported rows; Tasks is absent.",
"2026-09-03 (#424): tools/call `_meta` carries only progressToken from the cursor-vscode client; no `params.task`."
]
},
"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 accept for every value the route schema accepts; 2020-12 keeps positional precision through `prefixItems`, while draft-07 stays position-permissive because it applies the union to every element. Only the result-side check is observed; whether Cursor also validates arguments against `inputSchema` client-side is not recorded.",
"evidence": [
"2026-09-04 (#563): Cursor 3.18.25 rejected cargo-hauler's `hauler_status` result (`z.array(z.tuple([z.number().nullable(), z.number().int().nonnegative()]))` at `metrics.cargo_run_ms.buckets`) with `data/metrics/cargo_run_ms/buckets/0/0 boolean schema is false` for every tuple element, while the MCP TS SDK's own 2020-12 validator and the Workbench accepted the same result.",
"2026-09-05: reproduced locally with ajv 8.20.0's draft-07 class in non-strict mode (`new Ajv({ strict: false })`) on zod 4.5.4's `draft-2020-12` output — identical per-element `boolean schema is false` errors; the projected schema passes that validator and Ajv2020, and zod's `draft-7` target was rejected by Ajv2020 at compile time (`items must be object,boolean`), which is why the framework post-processes 2020-12 instead of switching dialect."
]
}
},
"noticeDelivery": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@
"evidence": [
"2026-09-04: packages/agent-bundle/tests/projection/mcp-in-memory.test.ts and packages/agent-bundle/tests/packed-stdio-projection.test.ts drive a task-augmented tools/call through the generated server with the SDK client; packages/workbench/tests/mcp-tasks.e2e.test.ts drives it from the Workbench against the spawned stdio artifact."
]
},
"structuredContentValidation": {
"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 non-strict 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'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) and by Ajv2020 (default-strict and `strict: false`) for valid tuple-bearing results, while the unprojected 2020-12 output fails non-strict 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."
]
}
},
"noticeDelivery": {
Expand Down
Loading
Loading