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
56 changes: 56 additions & 0 deletions apps/mcp/src/__tests__/intent-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
const schema = jsonSchemaPropertyToZod(prop);
return z4mini.toJSONSchema(schema as never, { target: 'draft-7', io: 'input' }) as Record<string, unknown>;
}

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<string, Record<string, unknown>> } };
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();
});
});
25 changes: 18 additions & 7 deletions apps/mcp/src/tools/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface Catalog {
// JSON Schema → Zod conversion (minimal, for MCP tool registration)
// ---------------------------------------------------------------------------

function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTypeAny {
export function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTypeAny {
const desc = prop.description as string | undefined;
const type = prop.type as string | undefined;

Expand All @@ -50,9 +50,17 @@ function jsonSchemaPropertyToZod(prop: Record<string, unknown>): 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<Record<string, unknown>> | 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();
}

Expand All @@ -69,9 +77,12 @@ function jsonSchemaPropertyToZod(prop: Record<string, unknown>): 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();
}
Expand Down
Loading