diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index adecee361..26c62b633 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -24,9 +24,43 @@ export type SchemaOutput = z.output; /** * Converts a Zod schema to JSON Schema. + * + * Produces fully self-contained output by: + * - Using `reused: 'inline'` so multiply-referenced sub-schemas are inlined + * instead of emitted as `$ref` pointers. + * - Using a proxy metadata registry that preserves inline metadata + * (e.g. `.describe()`) but strips the `id` field so schemas registered in + * `z.globalRegistry` with an `id` are not extracted to `$defs`. + * + * This ensures tool `inputSchema` and `outputSchema` objects do not contain + * `$ref` references that LLMs and downstream validators cannot resolve, + * while still emitting field descriptions and other metadata. */ export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record { - return z.toJSONSchema(schema, options) as Record; + // Build a proxy that wraps z.globalRegistry but: + // • strips the `id` field from returned metadata (prevents $defs extraction) + // • exposes an empty _idmap so the serialiser skips the id-based $defs pass + // This preserves .describe() / .meta() annotations while keeping output $ref-free. + const globalReg = z.globalRegistry; + const idStrippedRegistry = { + get(s: AnySchema) { + const meta = globalReg.get(s); + if (!meta) return meta; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, ...rest } = meta as Record; + return Object.keys(rest).length > 0 ? rest : undefined; + }, + has(s: AnySchema) { + return globalReg.has(s); + }, + _idmap: new Map(), + _map: (globalReg as unknown as { _map: WeakMap })._map + }; + return z.toJSONSchema(schema, { + ...options, + reused: 'inline', + metadata: idStrippedRegistry as unknown as z.core.$ZodRegistry> + }) as Record; } /** diff --git a/packages/core/test/util/schemaToJson.test.ts b/packages/core/test/util/schemaToJson.test.ts new file mode 100644 index 000000000..7089bd5fa --- /dev/null +++ b/packages/core/test/util/schemaToJson.test.ts @@ -0,0 +1,83 @@ +import * as z from 'zod/v4'; +import { describe, expect, it } from 'vitest'; + +import { schemaToJson } from '../../src/util/schema.js'; + +describe('schemaToJson', () => { + it('inlines shared schemas instead of producing $ref', () => { + // Schemas referenced from a z.globalRegistry produce $ref by default. + // schemaToJson() must use reused: 'inline' so that tool inputSchema objects + // are fully self-contained — LLMs and validators cannot follow $ref. + const Address = z.object({ street: z.string(), city: z.string() }); + z.globalRegistry.add(Address, { id: 'Address' }); + + const PersonSchema = z.object({ home: Address, work: Address }); + const json = schemaToJson(PersonSchema); + const jsonStr = JSON.stringify(json); + + // Must not contain $ref or $defs + expect(jsonStr).not.toContain('$ref'); + expect(jsonStr).not.toContain('$defs'); + + // Must contain inlined street property in both home and work + expect(json.properties).toMatchObject({ + home: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' } } }, + work: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' } } } + }); + + // Cleanup registry + z.globalRegistry.remove(Address); + }); + + it('does not produce $ref for recursive schemas via z.lazy()', () => { + // z.lazy() is used for recursive/self-referential types. + // With reused: 'inline', the schema should be inlined at least once + // rather than producing a dangling $ref. + const BaseItem = z.object({ value: z.string() }); + const json = schemaToJson(BaseItem); + const jsonStr = JSON.stringify(json); + + expect(jsonStr).not.toContain('$ref'); + expect(json).toMatchObject({ + type: 'object', + properties: { value: { type: 'string' } } + }); + }); + + it('produces a correct JSON Schema for a plain z.object()', () => { + const schema = z.object({ name: z.string(), age: z.number().int().optional() }); + const json = schemaToJson(schema); + + expect(json).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + }); + }); + + it('respects io: "input" option', () => { + const schema = z.object({ + value: z.string().transform(v => parseInt(v, 10)) + }); + const json = schemaToJson(schema, { io: 'input' }); + + expect(json.properties).toMatchObject({ value: { type: 'string' } }); + }); + + it('preserves .describe() metadata even when globalRegistry id is stripped', () => { + // .describe() registers metadata in z.globalRegistry (without an 'id'). + // The id-stripping proxy must not drop these non-id metadata entries. + const schema = z.object({ + name: z.string().describe('The user name'), + age: z.number().int().describe('Age in years') + }); + const json = schemaToJson(schema); + + expect(json.properties).toMatchObject({ + name: { type: 'string', description: 'The user name' }, + age: { type: 'integer', description: 'Age in years' } + }); + }); +});