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..f5c0f6e423 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,17 @@ function jsonSchemaPropertyToZod(prop: Record): z.ZodTypeAny { } } - // Complex schemas (oneOf, anyOf, allOf) — pass through as opaque; - // DocumentApi validates the actual payload at dispatch time. - if (prop.oneOf || prop.anyOf || prop.allOf) { + // 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'); + if (allObjectVariants) { + return desc ? z.looseObject({}).describe(desc) : z.looseObject({}); + } return desc ? z.unknown().describe(desc) : z.unknown(); } @@ -69,9 +77,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(); }