diff --git a/.changeset/563-interoperable-schema-projection.md b/.changeset/563-interoperable-schema-projection.md new file mode 100644 index 000000000..990d7c89e --- /dev/null +++ b/.changeset/563-interoperable-schema-projection.md @@ -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) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 2746b60d6..98525fdf1 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -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 `[] `. 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 diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 71ce80dc5..49839ec22 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -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 diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json index e96c5d6c8..73edd8d5b 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json @@ -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": { diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 139d1ca4d..56ef42284 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -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": { diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index aeda042fa..d2869122c 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -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": { diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 0a5bc95d9..f6bd0e148 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -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": { diff --git a/packages/agent-bundle/src/mcp-schema-projection.ts b/packages/agent-bundle/src/mcp-schema-projection.ts new file mode 100644 index 000000000..d7a86614c --- /dev/null +++ b/packages/agent-bundle/src/mcp-schema-projection.ts @@ -0,0 +1,212 @@ +/** + * Interoperable JSON Schema for the tool schemas a generated MCP server + * advertises (#563). + * + * The MCP SDK advertises a tool's `inputSchema`/`outputSchema` by asking the + * route's Standard Schema for its 2020-12 JSON Schema + * (`~standard.jsonSchema[io]({ target: 'draft-2020-12' })`) and validates + * `tools/call` arguments and `structuredContent` through `~standard.validate`. + * Hosts re-validate against the advertised JSON Schema with a validator of + * their own, and Cursor's applies draft-07 keyword semantics in non-strict + * mode: `prefixItems` is an unknown keyword it ignores, while `items: false` — + * zod's 2020-12 encoding of a fixed tuple such as + * `z.tuple([z.number().nullable(), z.number().int().nonnegative()])` — is + * applied to every element. Every element then fails and the host rejects a + * valid result with `MCP error -32602 … boolean schema is false`. Asking zod + * for the `draft-7` target instead is no answer: its `items: [A, B]` / + * `additionalItems` encoding fails to compile under every 2020-12 validator, + * the SDK's own included. + * + * So the 2020-12 output is post-processed. At every schema node with a + * non-empty `prefixItems` (after the prefix schemas are themselves projected): + * + * - `items: false` (closed tuple) becomes the deduplicated union of the + * positional schemas — `{ anyOf: [A, B] }`, or the bare schema when every + * position agrees — and `maxItems` becomes + * `min(existing ?? prefixItems.length, prefixItems.length)`. Under 2020-12 + * `items` only governs elements past `prefixItems`, of which `maxItems` now + * allows none, so the meaning is exact. Under draft-07 `prefixItems` is + * ignored and the union applies to every element, so a valid tuple passes + * (permissively: position 0 may also match B). `minItems` is kept. + * - `items: ` (open tuple, `.rest(R)`) becomes the deduplicated union + * of `[...prefixItems, R]`. Under 2020-12 this loosens only the rest + * positions, which may now also match a prefix schema — an accepted + * precision loss: no encoding is exact under 2020-12 and also passes a + * draft-07 validator for a rest tuple. + * - `items` absent or `true` is left alone; both drafts already accept every + * valid value. + * + * Nothing else is rewritten. `$schema` stays the 2020-12 dialect (the result + * is still valid 2020-12, so dialect-aware validators lose nothing); `$defs` + * needs no `definitions` alias because Ajv's core vocabulary resolves `$defs` + * in every draft; and the remaining 2020-12-only keywords + * (`unevaluatedProperties`, `dependentRequired`, …) are unknown to a lax + * draft-07 validator and therefore ignored rather than misapplied. The tuple + * encoding is the one construct zod 4 emits that such a validator rejects for + * valid data. + * + * The projection is pure: the input is never mutated, every schema node is + * copied, and recursion follows schema-bearing keywords only — never `const`, + * `enum`, `default`, or `examples`, whose values are carried over by reference. + */ +import { stableJson } from './core/digest.ts'; +import { isRecord } from './core/strict-json.ts'; + +/** + * Keywords whose value is one schema (an object or a boolean). `items` is + * handled apart: it may also hold a draft-07 positional schema array. + */ +const SINGLE_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + 'additionalItems', + 'additionalProperties', + 'contains', + 'contentSchema', + 'else', + 'if', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', +]); + +/** Keywords whose value is an array of schemas. */ +const SCHEMA_ARRAY_KEYWORDS: ReadonlySet = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']); + +/** Keywords whose value maps property names, patterns, or definition names to schemas. */ +const SCHEMA_MAP_KEYWORDS: ReadonlySet = new Set([ + '$defs', + 'definitions', + 'dependentSchemas', + 'patternProperties', + 'properties', +]); + +/** + * The union of a tuple's member schemas, deduplicated by canonical JSON; a + * single distinct member is emitted bare rather than as a one-member `anyOf`. + */ +const unionOf = (members: readonly unknown[]): unknown => { + const distinct = new Map(); + for (const member of members) { + const key = stableJson(member); + if (!distinct.has(key)) distinct.set(key, member); + } + const unique = [...distinct.values()]; + return unique.length === 1 ? unique[0] : { anyOf: unique }; +}; + +/** + * The tuple rule from the module comment, applied to a node whose keywords + * are already projected. The node is this module's own copy, so it is + * extended by spread rather than mutated in place: `items` keeps its + * position, `maxItems` keeps its position or is appended. + */ +const interoperableTuple = (schema: Readonly>): Readonly> => { + const prefixItems = schema['prefixItems']; + if (!Array.isArray(prefixItems) || prefixItems.length === 0) return schema; + const items = schema['items']; + if (items === false) { + const maxItems = schema['maxItems']; + return { + ...schema, + items: unionOf(prefixItems), + maxItems: typeof maxItems === 'number' ? Math.min(maxItems, prefixItems.length) : prefixItems.length, + }; + } + if (isRecord(items)) return { ...schema, items: unionOf([...prefixItems, items]) }; + return schema; +}; + +const projectSchemaArray = (schemas: readonly unknown[]): readonly unknown[] => + schemas.map((schema) => interoperableJsonSchema(schema)); + +const projectSchemaMap = (schemas: Readonly>): Readonly> => + Object.fromEntries(Object.entries(schemas).map(([name, schema]) => [name, interoperableJsonSchema(schema)])); + +/** + * Projects one keyword's value. A value that is not schema-bearing — or is + * not the shape its keyword calls for — passes through by reference. + */ +const projectKeyword = (keyword: string, value: unknown): unknown => { + if (keyword === 'items') return Array.isArray(value) ? projectSchemaArray(value) : interoperableJsonSchema(value); + if (SINGLE_SCHEMA_KEYWORDS.has(keyword)) return interoperableJsonSchema(value); + if (SCHEMA_ARRAY_KEYWORDS.has(keyword)) return Array.isArray(value) ? projectSchemaArray(value) : value; + if (SCHEMA_MAP_KEYWORDS.has(keyword)) return isRecord(value) ? projectSchemaMap(value) : value; + return value; +}; + +/** + * Pure post-processor: 2020-12 JSON Schema in, interoperable 2020-12 JSON + * Schema out, as a new structure. Boolean schemas and non-schema values are + * returned as they are. + */ +export const interoperableJsonSchema = (schema: unknown): unknown => { + if (!isRecord(schema)) return schema; + const projected: Record = {}; + for (const [keyword, value] of Object.entries(schema)) projected[keyword] = projectKeyword(keyword, value); + return interoperableTuple(projected); +}; + +/** The options a Standard JSON Schema converter receives (`target`, optional `libraryOptions`), forwarded verbatim. */ +type StandardJsonSchemaOptions = Readonly>; + +interface StandardJsonSchemaConverter { + readonly input: (options: StandardJsonSchemaOptions) => unknown; + readonly output: (options: StandardJsonSchemaOptions) => unknown; +} + +/** + * The `~standard` object of a Standard Schema that also implements Standard + * JSON Schema (zod ≥ 4.2). Structural, like the runtime's own probe, so the + * package takes no dependency on `@standard-schema/spec`. + */ +interface StandardSchemaProps { + readonly jsonSchema: StandardJsonSchemaConverter; + readonly validate: (value: unknown) => unknown; + readonly vendor: string; + readonly version: number; +} + +interface StandardSchemaWithJsonSchema { + readonly '~standard': StandardSchemaProps; +} + +const isStandardJsonSchemaConverter = (value: unknown): value is StandardJsonSchemaConverter => + isRecord(value) && typeof value['input'] === 'function' && typeof value['output'] === 'function'; + +const isStandardSchemaWithJsonSchema = (schema: unknown): schema is StandardSchemaWithJsonSchema => { + if (schema === null || schema === undefined) return false; + if (typeof schema !== 'object' && typeof schema !== 'function') return false; + const props = (schema as { readonly '~standard'?: unknown })['~standard']; + return isRecord(props) && typeof props['validate'] === 'function' && isStandardJsonSchemaConverter(props['jsonSchema']); +}; + +/** + * Wraps a Standard Schema that implements `~standard.jsonSchema` in one whose + * `jsonSchema.input(options)` / `.output(options)` return the interoperable + * projection of the original's answer to the same `options` — same `io`, so + * zod's `additionalProperties: false` stays an output-only detail — while + * `validate` delegates to the original `~standard` object (called as its + * method, so `this` is preserved). `version` is copied; `vendor` names this + * package. Anything else — `null`, `undefined`, a non-object, a value without + * `~standard`, or one whose `~standard` lacks `validate` or a complete + * `jsonSchema` — is returned unchanged, so the SDK applies its own conversion + * or error exactly as before. + */ +export const interoperableStandardSchema = (schema: T): T => { + if (!isStandardSchemaWithJsonSchema(schema)) return schema; + const std = schema['~standard']; + const wrapped: StandardSchemaWithJsonSchema = { + '~standard': { + jsonSchema: { + input: (options) => interoperableJsonSchema(std.jsonSchema.input(options)), + output: (options) => interoperableJsonSchema(std.jsonSchema.output(options)), + }, + validate: (value) => std.validate(value), + vendor: 'agent-bundle', + version: std.version, + }, + }; + return wrapped as T; +}; diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 46fc32f4c..069c80d84 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -29,8 +29,10 @@ import { runAgentRequest, unavailable, } from '@agent-bundle/runtime'; +import { isRecord } from './core/strict-json.ts'; import type { createEventRuntimeServer, EventRuntimeTransportError } from './events/ipc.ts'; import type { createCanonicalEventProps, projectEventDocument } from './events/project.ts'; +import { interoperableStandardSchema } from './mcp-schema-projection.ts'; import { createTaskAugmentedMcpServer, type TaskAugmentedMcpServer } from './mcp-tasks.ts'; import { canonicalAgentEvents, type CanonicalAgentEvent } from './routes/public.ts'; import { routeRenderLimits } from './routes/render-budget.ts'; @@ -295,7 +297,12 @@ const selectedConfig = ( keys.filter((key) => config[key] !== undefined).map((key) => [key, config[key]]), ); -/** The JSON Schema draft the MCP SDK targets when it advertises a Standard Schema. */ +/** + * The JSON Schema draft the MCP SDK targets when it advertises a Standard + * Schema. The object-rootedness probe below asks for the same draft; what the + * server then advertises is that draft's output passed through the + * interoperable projection (`mcp-schema-projection.ts`, #563). + */ const JSON_SCHEMA_TARGET = 'draft-2020-12'; interface StandardJsonSchemaSource { @@ -306,9 +313,6 @@ interface StandardJsonSchemaSource { }; } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - /** * A typeless JSON Schema root is object-shaped when it carries object keywords * or every member of its `oneOf`/`anyOf`/`allOf` composition is — the same @@ -333,6 +337,10 @@ const objectRootedJsonSchema = (schema: unknown): boolean => { * advertises none, while an object schema keeps the SDK's fail-closed output * validation. A schema that cannot describe itself as JSON Schema is handed * to the SDK unchanged so its own conversion decides. + * + * Returns the route's own schema (or `undefined`); registration wraps it in + * `interoperableStandardSchema`, so `tools/list` advertises the interoperable + * projection of its JSON Schema while validation stays the schema's own. */ export const advertisedOutputSchema = (schema: unknown): unknown => { const toJsonSchema = (schema as StandardJsonSchemaSource | null | undefined)?.['~standard']?.jsonSchema?.output; @@ -396,8 +404,8 @@ export const registerGeneratedRoutes = ( const outputSchema = advertisedOutputSchema(route.module.resultSchema); const registered = server.registerTool(route.name, { ...selectedConfig(route.config, ['_meta', 'annotations', 'description', 'icons', 'title']), - inputSchema: route.module.inputSchema, - ...(outputSchema === undefined ? {} : { outputSchema }), + inputSchema: interoperableStandardSchema(route.module.inputSchema), + ...(outputSchema === undefined ? {} : { outputSchema: interoperableStandardSchema(outputSchema) }), } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => settled(async () => { const clientName = server.server.getClientVersion()?.name; const rawArguments = options.rawArguments?.take(context.mcpReq.id); @@ -444,6 +452,7 @@ export const registerGeneratedRoutes = ( case 'prompt': server.registerPrompt(route.name, { ...selectedConfig(route.config, ['_meta', 'description', 'icons', 'title']), + // Not projected: prompts advertise flat `arguments` (name, description, required), not JSON Schema. argsSchema: route.module.inputSchema, } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => settled(async () => { const clientName = server.server.getClientVersion()?.name; diff --git a/packages/agent-bundle/tests/mcp-schema-projection.test.ts b/packages/agent-bundle/tests/mcp-schema-projection.test.ts new file mode 100644 index 000000000..d8c01944a --- /dev/null +++ b/packages/agent-bundle/tests/mcp-schema-projection.test.ts @@ -0,0 +1,505 @@ +/** + * Interoperable `inputSchema` / `outputSchema` for generated MCP tools (#563). + * + * The MCP SDK advertises a route's zod schemas as JSON Schema 2020-12, where a + * tuple is `prefixItems: [A, B]` plus `items: false`. Cursor validates + * `structuredContent` with ajv's draft-07 class in non-strict mode — the MCP + * SDK v1 default — so `prefixItems` is an unknown keyword it ignores and + * `items: false` applies to every element: every valid tuple fails with + * `data/…/buckets/0/0 boolean schema is false`. `mcp-schema-projection.ts` + * rewrites the 2020-12 output so a valid value passes under both dialects + * while positions and length stay exact under 2020-12, and the generated + * server wraps every tool route's schemas in it. These tests pin the rule, + * reproduce the failure with the real validators, and drive the wrapped + * schemas through the SDK's own server and client. + */ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv'; +import { McpServer } from '@modelcontextprotocol/server'; +import { describe, expect, it } from '@rstest/core'; +import { Ajv } from 'ajv/dist/ajv.js'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { z } from 'zod'; + +import type { AgentDocument, AgentRenderDispatcher, AgentRenderEvent } from '@agent-bundle/runtime'; + +import { deepFreeze } from '../src/core/freeze.ts'; +import { interoperableJsonSchema, interoperableStandardSchema } from '../src/mcp-schema-projection.ts'; +import { + createGeneratedRouteMcpServer, + type GeneratedRouteExecutionHost, + type GeneratedRouteRecord, + registerGeneratedRoutes, +} from '../src/mcp-server-runtime.ts'; + +type JsonSchema = Record; + +/** The conversion options the MCP SDK passes to every Standard Schema it advertises. */ +const TARGET = { target: 'draft-2020-12' } as const; + +interface StandardResult { + readonly issues?: readonly unknown[]; + readonly value?: unknown; +} + +/** The `~standard` surface zod (≥ 4.2) and the agent-bundle wrapper both expose. */ +interface StandardSurface { + readonly jsonSchema: { + readonly input: (options: typeof TARGET) => unknown; + readonly output: (options: typeof TARGET) => unknown; + }; + readonly validate: (value: unknown) => StandardResult | Promise; + readonly vendor: string; + readonly version: number; +} + +const standardOf = (schema: unknown): StandardSurface => + (schema as { readonly '~standard': StandardSurface })['~standard']; +const asSchema = (value: unknown): JsonSchema => value as JsonSchema; + +/** zod's own 2020-12 output — what the SDK advertises without the projection. */ +const zodJson = (schema: z.ZodType, io: 'input' | 'output' = 'output'): JsonSchema => + asSchema(standardOf(schema).jsonSchema[io](TARGET)); +const projected = (schema: z.ZodType, io: 'input' | 'output' = 'output'): JsonSchema => + asSchema(interoperableJsonSchema(zodJson(schema, io))); + +/** The draft-07 class resolves `$schema` against its own meta-schemas, so a Cursor-style validation drops the 2020-12 URI. */ +const stripSchema = (schema: JsonSchema): JsonSchema => + Object.fromEntries(Object.entries(schema).filter(([key]) => key !== '$schema')); + +/** Structural walk over every object node; a property literally named `items` would count too, and no fixture declares one. */ +const someNode = (value: unknown, predicate: (node: JsonSchema) => boolean): boolean => { + if (Array.isArray(value)) return value.some((entry) => someNode(entry, predicate)); + if (typeof value !== 'object' || value === null) return false; + const node = asSchema(value); + return predicate(node) || Object.values(node).some((entry) => someNode(entry, predicate)); +}; +const containsItemsFalse = (value: unknown): boolean => someNode(value, (node) => node['items'] === false); +const containsPrefixItems = (value: unknown): boolean => someNode(value, (node) => Array.isArray(node['prefixItems'])); + +interface Verdict { + readonly errors: string; + readonly valid: boolean; +} + +const verdict = (engine: Pick, schema: JsonSchema, value: unknown): Verdict => { + const validate = engine.compile(schema); + const valid = validate(value); + return { errors: valid ? '' : engine.errorsText(validate.errors), valid }; +}; +/** Cursor's validator: ajv's draft-07 class, non-strict — `prefixItems` is ignored and `items` governs every element. */ +const cursorVerdict = (schema: JsonSchema, value: unknown): Verdict => verdict(new Ajv({ strict: false }), stripSchema(schema), value); +const lax2020Verdict = (schema: JsonSchema, value: unknown): Verdict => verdict(new Ajv2020({ strict: false }), schema, value); +/** Default-strict 2020-12, with ajv's strict-mode advisories captured instead of written to the console. */ +const strict2020Verdict = (schema: JsonSchema, value: unknown): Verdict & { readonly advisories: readonly string[] } => { + const advisories: string[] = []; + const record = (...args: unknown[]): number => advisories.push(args.map(String).join(' ')); + return { ...verdict(new Ajv2020({ logger: { error: record, log: record, warn: record } }), schema, value), advisories }; +}; + +const histogram = z.object({ + buckets: z.array(z.tuple([z.number().nullable(), z.number().int().nonnegative()])), + count: z.number().int().nonnegative(), + max: z.number().nullable(), + min: z.number().nullable(), + sum: z.number(), +}); +/** Mirrors cargo-hauler's `protocol-schemas.ts` (`histogramMetricSchema` inside `statusMetricsSchema`) — the shape from the issue. */ +const cargoMetrics = z.object({ + metrics: z.object({ + cargo_run_ms: histogram, + cargo_run_ms_by_kind: z.record(z.string(), histogram).optional(), + wait_ms_summary: z.object({ + count: z.number().int().nonnegative(), + max: z.number().nullable(), + min: z.number().nullable(), + quantiles: z.array(z.tuple([z.number(), z.number().nullable()])), + sum: z.number(), + }).optional(), + }), +}); +const bucketed: z.output = { buckets: [[null, 3], [12.5, 0]], count: 3, max: 12.5, min: null, sum: 12.5 }; +const cargoSample: z.output = { + metrics: { + cargo_run_ms: bucketed, + cargo_run_ms_by_kind: { build: bucketed }, + wait_ms_summary: { count: 1, max: 1, min: 1, quantiles: [[0.5, null], [0.95, 7]], sum: 1 }, + }, +}; +const withBuckets = (buckets: unknown): unknown => ({ metrics: { cargo_run_ms: { ...bucketed, buckets } } }); + +const Node = z.object({ + get children() { + return z.array(Node); + }, +}); +const invalidNode = { root: { children: [{ children: 'x' }] } }; + +interface Fixture { + readonly name: string; + /** Fails the projected schema under 2020-12 by position or length — the precision `prefixItems` and `maxItems` keep. */ + readonly rejectedBy2020: unknown; + /** Carries an element matching none of the positional schemas, so even the permissive draft-07 reading rejects it. */ + readonly rejectedByBoth: unknown; + /** How zod's unprojected output fails Cursor's validator; `none` for a shape that never did. */ + readonly reproduction: 'closed-tuple' | 'rest-tuple' | 'none'; + readonly sample: unknown; + readonly schema: z.ZodType; +} + +const fixtures: readonly Fixture[] = [ + { + name: 'tuple', reproduction: 'closed-tuple', schema: z.object({ t: z.tuple([z.string(), z.number()]) }), + sample: { t: ['a', 1] }, rejectedBy2020: { t: ['a', 1, 2] }, rejectedByBoth: { t: ['a', true] }, + }, + { + name: 'nullable tuple element', reproduction: 'closed-tuple', schema: z.object({ t: z.tuple([z.number().nullable(), z.number()]) }), + sample: { t: [null, 1] }, rejectedBy2020: { t: [1, null] }, rejectedByBoth: { t: ['x', 1] }, + }, + { + name: 'nested tuple in object', reproduction: 'closed-tuple', + schema: z.object({ o: z.object({ t: z.tuple([z.tuple([z.number(), z.number()]), z.string()]) }) }), + sample: { o: { t: [[1, 2], 'a'] } }, rejectedBy2020: { o: { t: ['a', [1, 2]] } }, rejectedByBoth: { o: { t: [[1, 'x'], 'a'] } }, + }, + { + name: 'array of tuples', reproduction: 'closed-tuple', schema: z.object({ rows: z.array(z.tuple([z.string(), z.number()])) }), + sample: { rows: [['a', 1], ['b', 2]] }, rejectedBy2020: { rows: [['a', 1], ['b', 2, 3]] }, rejectedByBoth: { rows: [['a', true]] }, + }, + { + name: 'rest tuple', reproduction: 'rest-tuple', schema: z.object({ t: z.tuple([z.string()]).rest(z.number()) }), + sample: { t: ['a', 1, 2] }, rejectedBy2020: { t: [1, 2] }, rejectedByBoth: { t: ['a', true] }, + }, + { + name: 'cargo-hauler metrics', reproduction: 'closed-tuple', schema: cargoMetrics, + sample: cargoSample, rejectedBy2020: withBuckets([[3, null]]), rejectedByBoth: withBuckets([['x', 3]]), + }, + { + name: 'recursive $defs/$ref', reproduction: 'none', schema: z.object({ root: Node }), + sample: { root: { children: [{ children: [] }] } }, rejectedBy2020: invalidNode, rejectedByBoth: invalidNode, + }, +]; + +describe('interoperableJsonSchema', () => { + it('rewrites a closed tuple to the worked example: prefixItems kept, items the positional union, length pinned', () => { + const nullableNumber = { type: ['number', 'null'] }; + const nonNegativeInt = { maximum: 9007199254740991, minimum: 0, type: 'integer' }; + expect(projected(z.object({ t: z.tuple([z.number().nullable(), z.number().int().nonnegative()]) }))).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + properties: { + t: { items: { anyOf: [nullableNumber, nonNegativeInt] }, maxItems: 2, minItems: 2, prefixItems: [nullableNumber, nonNegativeInt], type: 'array' }, + }, + required: ['t'], + type: 'object', + }); + }); + + it('leaves no items: false anywhere in any fixture, where zod emitted one for every closed tuple', () => { + for (const fixture of fixtures) { + expect(containsItemsFalse(projected(fixture.schema))).toBe(false); + expect(containsItemsFalse(zodJson(fixture.schema))).toBe(fixture.reproduction === 'closed-tuple'); + } + }); + + it('collapses structurally equal positions to one bare schema instead of a one-member anyOf', () => { + expect(projected(z.tuple([z.number(), z.number()]))['items']).toEqual({ type: 'number' }); + }); + + it('unions the rest schema with the prefix schemas of an open tuple and adds no maxItems', () => { + const result = projected(z.tuple([z.string()]).rest(z.number())); + expect(result['items']).toEqual({ anyOf: [{ type: 'string' }, { type: 'number' }] }); + expect(result['minItems']).toBe(1); + expect(result).not.toHaveProperty('maxItems'); + }); + + it('keeps the minItems/maxItems zod emits for an optional trailing element', () => { + expect(projected(z.tuple([z.string(), z.number().optional()]))) + .toMatchObject({ items: { anyOf: [{ type: 'string' }, { type: 'number' }] }, maxItems: 2, minItems: 1 }); + }); + + it('never mutates its input and returns a new object', () => { + const input = deepFreeze(structuredClone(zodJson(cargoMetrics))); + const before = JSON.stringify(input); + const result = interoperableJsonSchema(input); + expect(result).not.toBe(input); + expect(JSON.stringify(input)).toBe(before); + expect(containsItemsFalse(result)).toBe(false); + }); + + it('does not walk const, enum, default, or examples payloads that merely look like schemas', () => { + const payloads: Record = { + const: { items: false, prefixItems: [1] }, + default: { items: false }, + enum: [{ items: false }], + examples: [{ items: false, prefixItems: [] }], + }; + const schema = { + properties: Object.fromEntries(Object.entries(payloads).map(([keyword, payload]) => [keyword, { [keyword]: payload }])), + type: 'object', + }; + const result = asSchema(asSchema(interoperableJsonSchema(schema))['properties']); + for (const [keyword, payload] of Object.entries(payloads)) { + expect(JSON.stringify(asSchema(result[keyword])[keyword])).toBe(JSON.stringify(payload)); + } + }); + + it('passes prefixItems without items, items: true, boolean schemas, and non-objects through unchanged', () => { + const open = { prefixItems: [{ type: 'string' }], type: 'array' }; + const anything = { items: true, prefixItems: [{ type: 'string' }], type: 'array' }; + expect(interoperableJsonSchema(open)).toEqual(open); + expect(interoperableJsonSchema(anything)).toEqual(anything); + for (const value of [true, false, 42, 'x', null]) expect(interoperableJsonSchema(value)).toBe(value); + }); + + it('pins maxItems to the prefix length so a closed tuple stays closed under 2020-12', () => { + const closed = { items: false, prefixItems: [{ type: 'string' }], type: 'array' }; + const expected = { items: { type: 'string' }, maxItems: 1, prefixItems: [{ type: 'string' }], type: 'array' }; + expect(interoperableJsonSchema(closed)).toEqual(expected); + expect(interoperableJsonSchema({ ...closed, maxItems: 5 })).toEqual(expected); + }); +}); + +describe('validator matrix', () => { + for (const fixture of fixtures) { + describe(fixture.name, () => { + it("accepts a value the zod schema accepts under Cursor's draft-07 validator once projected", () => { + expect(() => fixture.schema.parse(fixture.sample)).not.toThrow(); + expect(cursorVerdict(projected(fixture.schema), fixture.sample)).toEqual({ errors: '', valid: true }); + }); + + it(fixture.reproduction === 'none' + ? "passed Cursor's validator before the projection too, so nothing is rewritten" + : "reproduces the Cursor rejection on zod's unprojected output", () => { + const before = cursorVerdict(zodJson(fixture.schema), fixture.sample); + switch (fixture.reproduction) { + case 'closed-tuple': + expect(before.valid).toBe(false); + expect(before.errors).toContain('boolean schema is false'); + break; + case 'rest-tuple': + expect(before.valid).toBe(false); + expect(before.errors).toContain('must be'); + break; + case 'none': + expect(before).toEqual({ errors: '', valid: true }); + expect(projected(fixture.schema)).toEqual(zodJson(fixture.schema)); + break; + default: { + const unreachable: never = fixture.reproduction; + throw new TypeError(`Unhandled reproduction ${String(unreachable)}.`); + } + } + }); + + it('stays a valid 2020-12 schema under lax and default-strict ajv with $schema kept', () => { + const interoperable = projected(fixture.schema); + expect(lax2020Verdict(interoperable, fixture.sample)).toEqual({ errors: '', valid: true }); + const strict = strict2020Verdict(interoperable, fixture.sample); + expect(strict.valid).toBe(true); + // ajv's strictTuples advisory fires for rest and optional-tail tuples as zod emits them; the projection adds none. + const before = strict2020Verdict(zodJson(fixture.schema), fixture.sample).advisories; + expect(strict.advisories.filter((line) => !before.includes(line))).toEqual([]); + }); + + it('still rejects an invalid value under 2020-12 — a wrong position or an extra element for a tuple', () => { + expect(() => fixture.schema.parse(fixture.rejectedBy2020)).toThrow(); + expect(lax2020Verdict(projected(fixture.schema), fixture.rejectedBy2020).valid).toBe(false); + }); + + it('rejects an invalid value under draft-07 as well — an element no positional schema admits for a tuple', () => { + const interoperable = projected(fixture.schema); + expect(cursorVerdict(interoperable, fixture.rejectedByBoth).valid).toBe(false); + expect(lax2020Verdict(interoperable, fixture.rejectedByBoth).valid).toBe(false); + }); + }); + } + + it("reproduces Cursor's exact error text on the unprojected cargo-hauler shape", () => { + expect(cursorVerdict(zodJson(cargoMetrics), cargoSample).errors).toBe('data/metrics/cargo_run_ms/buckets/0/0 boolean schema is false'); + }); + + it('needs no $defs rewrite: recursion passes strict draft-07 and 2020-12 unprojected', () => { + const recursive = z.object({ root: Node }); + const sample = { root: { children: [{ children: [] }] } }; + expect(verdict(new Ajv(), stripSchema(zodJson(recursive)), sample)).toEqual({ errors: '', valid: true }); + expect(strict2020Verdict(zodJson(recursive), sample).valid).toBe(true); + }); +}); + +describe('interoperableStandardSchema', () => { + it('returns anything that is not a Standard Schema with JSON Schema support unchanged', () => { + const opaque = { parse: (value: unknown) => value }; + const validateOnly = { '~standard': { validate: (value: unknown) => ({ value }), vendor: 'x', version: 1 } }; + const jsonOnly = { '~standard': { jsonSchema: { input: () => ({}), output: () => ({}) }, vendor: 'x', version: 1 } }; + for (const value of [undefined, null, {}, opaque, validateOnly, jsonOnly]) { + expect(interoperableStandardSchema(value)).toBe(value); + } + }); + + it('wraps a zod schema in a new Standard Schema branded agent-bundle at the same version', () => { + const wrapped = interoperableStandardSchema(cargoMetrics); + expect(wrapped).not.toBe(cargoMetrics); + expect(standardOf(wrapped).vendor).toBe('agent-bundle'); + expect(standardOf(wrapped).version).toBe(1); + expect(standardOf(cargoMetrics).vendor).toBe('zod'); + }); + + it('delegates validation to the wrapped schema', async () => { + const std = standardOf(interoperableStandardSchema(cargoMetrics)); + const accepted = await std.validate(cargoSample); + expect(accepted.issues).toBeUndefined(); + expect(accepted.value).toEqual(cargoSample); + const rejected = await std.validate(withBuckets([['x', 3]])); + expect(rejected.issues?.length ?? 0).toBeGreaterThanOrEqual(1); + }); + + it('projects both io variants and keeps the io distinction zod draws', () => { + const std = standardOf(interoperableStandardSchema(cargoMetrics)); + const output = asSchema(std.jsonSchema.output(TARGET)); + const input = asSchema(std.jsonSchema.input(TARGET)); + expect(output).toEqual(projected(cargoMetrics, 'output')); + expect(input).toEqual(projected(cargoMetrics, 'input')); + expect(output['additionalProperties']).toBe(false); + expect(input).not.toHaveProperty('additionalProperties'); + expect(containsItemsFalse(output) || containsItemsFalse(input)).toBe(false); + expect(containsPrefixItems(output) && containsPrefixItems(input)).toBe(true); + }); +}); + +/** A generated server host that cannot render: `tools/list` never asks it to. */ +const stubHost: GeneratedRouteExecutionHost = { + availability: () => 'available', + close: async () => undefined, + execute: async () => { + throw new Error('not rendered'); + }, + identity: { artifactEpoch: 'epoch', instanceId: 'test' }, + markUnavailable: () => undefined, +}; + +const metricsInput = z.object({ range: z.tuple([z.number(), z.number()]).optional(), verbose: z.boolean().optional() }); +const metricsRoute: GeneratedRouteRecord = { + config: {}, + id: 'mcp/metrics/tools/metrics', + kind: 'tool', + module: { default: () => undefined, inputSchema: metricsInput, resultSchema: cargoMetrics }, + name: 'metrics', +}; + +/** Connects the pair over an in-memory transport, runs the scenario, and closes both whatever happens. */ +const withSession = async (server: McpServer, client: Client, scenario: () => Promise): Promise => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + await scenario(); + } finally { + await client.close(); + await server.close(); + } +}; +const within = (): { readonly signal: AbortSignal } => ({ signal: AbortSignal.timeout(5_000) }); + +/** The tool as `tools/list` advertises it — the listing also primes the client's own output validation for `callTool`. */ +const advertised = async (client: Client, name: string): Promise<{ readonly inputSchema: JsonSchema; readonly outputSchema: JsonSchema }> => { + const { tools } = await client.listTools(undefined, within()); + const tool = tools.find((candidate) => candidate.name === name); + return { inputSchema: asSchema(tool?.inputSchema), outputSchema: asSchema(tool?.outputSchema) }; +}; + +/** + * A client validating `structuredContent` the way MCP SDK v1 hosts (Cursor among + * them) do: ajv's draft-07 class, non-strict, meta-schema validation off — the + * construction the SDK documents as its pre-SEP-1613 default (`validators/ajv`), + * minus `ajv-formats`, which no fixture needs. + */ +const cursorEquivalentClient = (): Client => new Client( + { name: 'cursor-equivalent', version: '0.0.0' }, + { jsonSchemaValidator: new AjvJsonSchemaValidator(new Ajv({ allErrors: true, strict: false, validateFormats: true, validateSchema: false })) }, +); + +const expectInteroperable = (schema: JsonSchema): void => { + expect(containsItemsFalse(schema)).toBe(false); + expect(containsPrefixItems(schema)).toBe(true); + expect(() => new Ajv({ strict: false }).compile(stripSchema(schema))).not.toThrow(); + expect(() => new Ajv2020({ strict: false }).compile(schema)).not.toThrow(); +}; + +describe('generated server schema advertisement', () => { + it('advertises projected inputSchema and outputSchema through createGeneratedRouteMcpServer', async () => { + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + host: stubHost, + plugin: { name: 'metrics', version: '0.0.0' }, + routes: { [metricsRoute.id]: metricsRoute }, + }); + const client = new Client({ name: 'list-tools', version: '0.0.0' }); + await withSession(server, client, async () => { + const { inputSchema, outputSchema } = await advertised(client, 'metrics'); + expectInteroperable(inputSchema); + expectInteroperable(outputSchema); + expect(asSchema(inputSchema['properties'])['range']).toEqual({ + items: { type: 'number' }, maxItems: 2, minItems: 2, prefixItems: [{ type: 'number' }, { type: 'number' }], type: 'array', + }); + // io preserved through the wrapper: zod closes objects for output only. + expect(inputSchema).not.toHaveProperty('additionalProperties'); + expect(outputSchema['additionalProperties']).toBe(false); + }); + }); + + it('serves a tools/call whose structured content a Cursor-equivalent client accepts against the advertised outputSchema', async () => { + // The Flight worker needs React's `react-server` condition (the projection + // pool), so the dispatcher completes with a fixed document; the SDK's + // argument validation, `renderGeneratedRoute`, `resultSchema.parse`, the + // structured-content attachment, the SDK's output validation, and the + // client's own output validation all run for real. + const root: AgentDocument['root'] = { children: [{ kind: 'text', text: 'metrics' }], kind: 'result' }; + const document: AgentDocument = { root, status: 'success', value: cargoSample, version: 1 }; + const invocations: unknown[] = []; + const dispatcher: AgentRenderDispatcher = { + dispatch: async () => document, + stream: (request) => { + invocations.push(request.invocation); + return new ReadableStream({ + start(controller) { + controller.enqueue({ document, sequence: 1, type: 'complete' }); + controller.close(); + }, + }); + }, + }; + const server = new McpServer({ name: 'metrics', version: '0.0.0' }); + registerGeneratedRoutes(server, { [metricsRoute.id]: metricsRoute }, dispatcher, 'epoch'); + const client = cursorEquivalentClient(); + await withSession(server, client, async () => { + const { outputSchema } = await advertised(client, 'metrics'); + const result = await client.callTool({ arguments: { range: [1, 2] }, name: 'metrics' }, within()); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toEqual(cargoSample); + expect(cursorVerdict(outputSchema, result.structuredContent)).toEqual({ errors: '', valid: true }); + expect(invocations).toEqual([{ kind: 'tool', props: { input: { range: [1, 2] }, operationId: metricsRoute.id } }]); + // The wrapper still validates: the SDK reports the input failure as a tool error. + const rejected = await client.callTool({ arguments: { range: ['1', 2] }, name: 'metrics' }, within()); + expect(rejected.isError).toBe(true); + expect(JSON.stringify(rejected.content)).toContain('Input validation error'); + }); + }); + + it("reproduces the Cursor rejection on raw zod schemas and clears it with the wrapper on the SDK's own McpServer", async () => { + const respond = async () => ({ content: [{ text: 'metrics', type: 'text' as const }], structuredContent: cargoSample }); + const server = new McpServer({ name: 'metrics', version: '0.0.0' }); + server.registerTool('raw', { inputSchema: metricsInput, outputSchema: cargoMetrics }, respond); + server.registerTool('wrapped', { + inputSchema: interoperableStandardSchema(metricsInput), + outputSchema: interoperableStandardSchema(cargoMetrics), + }, respond); + const client = cursorEquivalentClient(); + await withSession(server, client, async () => { + await client.listTools(undefined, within()); + await expect(client.callTool({ arguments: { range: [1, 2] }, name: 'raw' }, within())) + .rejects.toThrow('data/metrics/cargo_run_ms/buckets/0/0 boolean schema is false'); + const result = await client.callTool({ arguments: { range: [1, 2] }, name: 'wrapped' }, within()); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toEqual(cargoSample); + }); + }); +}); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index d10151de1..0b48b2d29 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -66,6 +66,22 @@ The lowered value is an `McpCallToolResult` (exported from `@agent-bundle/runtim against either SDK accepts it as is; `attachMcpStructuredContent` returns whatever result type it was given. +The schemas reach the host as JSON Schema: `tools/list` advertises `inputSchema` and, for a +`resultSchema` that describes an object, `outputSchema` in the 2020-12 dialect, projected so that +2020-12 validators and non-strict draft-07 validators both accept every valid value. A zod tuple +(`z.tuple([...])`) is emitted as `prefixItems` plus `items` set to the union of the positional +schemas, with `minItems` (and, for a closed tuple, `maxItems`) bounding the length — never +`items: false` — so a host that checks `structuredContent` with draft-07 keyword semantics +(Cursor, which otherwise rejects a valid tuple result with +`MCP error -32602 … boolean schema is false`) accepts everything a 2020-12 validator accepts. +`inputSchema` goes through the same projection, so a host that checks arguments the same way is +covered too. A `.rest()` tuple is the one place 2020-12 precision is loosened: its rest positions +may also match a prefix schema. Everything else zod emits passes through untouched: keywords +both dialects share (`$ref`, `propertyNames`, `const`, numeric `exclusiveMinimum`) validate the +same way under both, `$defs` is reached through JSON-pointer `$ref`s under either, and a +2020-12-only keyword such as `unevaluatedProperties` or `dependentRequired` is ignored by a lax +draft-07 validator rather than misapplied. + A route may re-export its component and schemas from another module. This is how one tool is placed on two generated servers when only `config` differs between the placements — an MCP App `tools/call`, for example, reaches the server that served the widget: diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 2a2d313b6..0f9aa6296 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -61,6 +61,19 @@ export default async function Status({ input, signal }: ToolRouteProps { return m.notApplicable; }; +/** + * The per-host details table for one `mcp.` capability row (`tasks`, + * `structuredContentValidation`): state, reason, and the evidence-note count. + * A host whose table lacks the row is skipped rather than rendered as `—`. + */ +const mcpRowDetails = (hosts: readonly HostCapabilityTable[], row: string, m: Messages): string => + table( + [m.headers.host, m.headers.state, m.headers.detail], + hosts.flatMap(host => { + const entry = capabilityRow(asObject(host.data.mcp)[row]); + if (entry === undefined) { + return []; + } + const details: string[] = []; + if (entry.reason !== undefined) { + details.push(escapeProse(entry.reason)); + } + if (Array.isArray(entry.evidence)) { + details.push(m.evidenceNotes(entry.evidence.length)); + } + return [[code(host.host), entry.state ?? m.unavailable, details.length > 0 ? details.join('
') : m.notApplicable]]; + }), + ); + const unionKeys = (hosts: readonly HostCapabilityTable[], select: (data: JsonObject) => JsonObject): string[] => [...new Set(hosts.flatMap(host => Object.keys(select(host.data))))].sort(); @@ -557,25 +587,10 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string ); sections.push(m.mcpTasksIntro); sections.push(`### ${m.mcpTasksDetails}\n`); - sections.push( - table( - [m.headers.host, m.headers.state, m.headers.detail], - hosts.flatMap(host => { - const entry = capabilityRow(asObject(host.data.mcp).tasks); - if (entry === undefined) { - return []; - } - const details: string[] = []; - if (entry.reason !== undefined) { - details.push(escapeProse(entry.reason)); - } - if (Array.isArray(entry.evidence)) { - details.push(m.evidenceNotes(entry.evidence.length)); - } - return [[code(host.host), entry.state ?? m.unavailable, details.length > 0 ? details.join('
') : m.notApplicable]]; - }), - ), - ); + sections.push(mcpRowDetails(hosts, 'tasks', m)); + sections.push(m.mcpStructuredContentValidationIntro); + sections.push(`### ${m.mcpStructuredContentValidationDetails}\n`); + sections.push(mcpRowDetails(hosts, 'structuredContentValidation', m)); sections.push(`## ${m.lineage}\n`); sections.push(m.lineageIntro);