From 6ec8ab75ba406bbabcb716e9f9e65d750fd90a72 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 7 May 2026 18:01:51 -0300 Subject: [PATCH 1/2] fix(mcp): fall back to z.unknown() for oneOf with non-object variants z.looseObject({}) emits type:"object" which is right for object-only unions but rejects arrays/booleans/etc. at runtime when the union includes non-object variants. Gate the looseObject path on "every variant is type:object" and fall back to z.unknown() otherwise. The only catalog field this affects today is superdoc_edit.content (oneOf object|array), where the array form was getting rejected before reaching DocumentApi. Adds a unit test that walks the catalog and checks the emitted type for both branches. --- apps/mcp/src/__tests__/intent-schema.test.ts | 56 ++++++++++++++++++++ apps/mcp/src/tools/intent.ts | 27 +++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 apps/mcp/src/__tests__/intent-schema.test.ts diff --git a/apps/mcp/src/__tests__/intent-schema.test.ts b/apps/mcp/src/__tests__/intent-schema.test.ts new file mode 100644 index 0000000000..d38a551dae --- /dev/null +++ b/apps/mcp/src/__tests__/intent-schema.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'bun:test'; +import * as z4mini from 'zod/v4-mini'; +import { jsonSchemaPropertyToZod } from '../tools/intent.js'; +import { MCP_TOOL_CATALOG } from '../generated/catalog.js'; + +function emit(prop: Record) { + const schema = jsonSchemaPropertyToZod(prop); + return z4mini.toJSONSchema(schema as never, { target: 'draft-7', io: 'input' }) as Record; +} + +describe('jsonSchemaPropertyToZod', () => { + it("emits type:'object' for plain object props", () => { + expect(emit({ type: 'object' }).type).toBe('object'); + }); + + it("emits type:'object' for oneOf where every variant is object-typed", () => { + expect( + emit({ + oneOf: [ + { type: 'object', properties: {} }, + { type: 'object', properties: { x: { type: 'string' } } }, + ], + }).type, + ).toBe('object'); + }); + + it('omits type for oneOf containing a non-object variant (object|array)', () => { + const out = emit({ + oneOf: [{ type: 'object' }, { type: 'array', items: { type: 'object' } }], + }); + expect(out.type).toBeUndefined(); + }); + + it('omits type for oneOf containing a non-object variant (boolean|object)', () => { + const out = emit({ oneOf: [{ type: 'boolean' }, { type: 'object' }] }); + expect(out.type).toBeUndefined(); + }); + + it('handles anyOf and allOf the same way as oneOf', () => { + expect(emit({ anyOf: [{ type: 'object' }, { type: 'object' }] }).type).toBe('object'); + expect(emit({ allOf: [{ type: 'object' }, { type: 'object' }] }).type).toBe('object'); + expect(emit({ anyOf: [{ type: 'string' }, { type: 'object' }] }).type).toBeUndefined(); + }); + + it('falls back to z.unknown() for top-level oneOf with non-object variant in real catalog (superdoc_edit.content)', () => { + type Tool = { toolName: string; inputSchema: { properties?: Record> } }; + const catalog = MCP_TOOL_CATALOG as { tools: Tool[] }; + const edit = catalog.tools.find((t) => t.toolName === 'superdoc_edit'); + const content = edit?.inputSchema?.properties?.content; + expect(content?.oneOf).toBeDefined(); + const variants = content!.oneOf as Array<{ type?: string }>; + const hasNonObject = variants.some((v) => v.type !== 'object'); + expect(hasNonObject).toBe(true); + expect(emit(content!).type).toBeUndefined(); + }); +}); diff --git a/apps/mcp/src/tools/intent.ts b/apps/mcp/src/tools/intent.ts index eaca2a6c89..d7694ea837 100644 --- a/apps/mcp/src/tools/intent.ts +++ b/apps/mcp/src/tools/intent.ts @@ -39,7 +39,7 @@ interface Catalog { // JSON Schema → Zod conversion (minimal, for MCP tool registration) // --------------------------------------------------------------------------- -function jsonSchemaPropertyToZod(prop: Record): z.ZodTypeAny { +export function jsonSchemaPropertyToZod(prop: Record): z.ZodTypeAny { const desc = prop.description as string | undefined; const type = prop.type as string | undefined; @@ -50,9 +50,21 @@ function jsonSchemaPropertyToZod(prop: Record): z.ZodTypeAny { } } - // Complex schemas (oneOf, anyOf, allOf) — pass through as opaque; + // Complex schemas (oneOf, anyOf, allOf): + // When every variant is `type: "object"`, emit z.looseObject({}) so the + // JSON Schema carries `type: "object"`. z.unknown() drops the type and + // some MCP clients (notably the Claude Code harness) then treat the + // value as a string, breaking object payloads. + // When any variant is non-object (e.g. object|array, boolean|object), + // fall back to z.unknown(). z.looseObject({}) would reject the non-object + // variants at the zod layer before DocumentApi sees them. // DocumentApi validates the actual payload at dispatch time. - if (prop.oneOf || prop.anyOf || prop.allOf) { + const variants = (prop.oneOf ?? prop.anyOf ?? prop.allOf) as Array> | undefined; + if (variants) { + const allObjectVariants = variants.every((v) => v?.type === 'object'); + if (allObjectVariants) { + return desc ? z.looseObject({}).describe(desc) : z.looseObject({}); + } return desc ? z.unknown().describe(desc) : z.unknown(); } @@ -69,9 +81,12 @@ function jsonSchemaPropertyToZod(prop: Record): z.ZodTypeAny { // z4-mini toJSONSchema cannot convert z.record() from zod v4 classic. return desc ? z.array(z.unknown()).describe(desc) : z.array(z.unknown()); case 'object': - // Use z.unknown() instead of z.record() to avoid MCP SDK Zod v4 classic/mini - // incompatibility. DocumentApi validates the actual shape at dispatch time. - return desc ? z.unknown().describe(desc) : z.unknown(); + // Use z.looseObject({}) so the emitted JSON Schema carries + // `type: "object"`. z.unknown() drops the type (clients treat it + // as a string); z.record() can't be converted by the MCP SDK's + // z4-mini toJSONSchema. DocumentApi validates the actual shape + // at dispatch time. + return desc ? z.looseObject({}).describe(desc) : z.looseObject({}); default: return desc ? z.unknown().describe(desc) : z.unknown(); } From 574c0a50ca12c1f0f8ebd059b97850bc4a27edd2 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 7 May 2026 18:05:43 -0300 Subject: [PATCH 2/2] docs(mcp): tighten oneOf branch comment with AIDEV-NOTE anchor --- apps/mcp/src/tools/intent.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/mcp/src/tools/intent.ts b/apps/mcp/src/tools/intent.ts index d7694ea837..f5c0f6e423 100644 --- a/apps/mcp/src/tools/intent.ts +++ b/apps/mcp/src/tools/intent.ts @@ -50,15 +50,11 @@ export function jsonSchemaPropertyToZod(prop: Record): z.ZodTyp } } - // Complex schemas (oneOf, anyOf, allOf): - // When every variant is `type: "object"`, emit z.looseObject({}) so the - // JSON Schema carries `type: "object"`. z.unknown() drops the type and - // some MCP clients (notably the Claude Code harness) then treat the - // value as a string, breaking object payloads. - // When any variant is non-object (e.g. object|array, boolean|object), - // fall back to z.unknown(). z.looseObject({}) would reject the non-object - // variants at the zod layer before DocumentApi sees them. - // DocumentApi validates the actual payload at dispatch time. + // AIDEV-NOTE: oneOf/anyOf/allOf must gate on "every variant is type:object". + // looseObject({}) emits type:"object" (so MCP clients send objects, not strings) + // but rejects non-object payloads at the zod layer. For mixed unions like + // superdoc_edit.content (object|array), fall back to z.unknown() so the array + // form survives. DocumentApi validates the actual shape at dispatch time. const variants = (prop.oneOf ?? prop.anyOf ?? prop.allOf) as Array> | undefined; if (variants) { const allObjectVariants = variants.every((v) => v?.type === 'object');