Skip to content
Open
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
36 changes: 35 additions & 1 deletion packages/core/src/util/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,43 @@ export type SchemaOutput<T extends AnySchema> = z.output<T>;

/**
* 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<string, unknown> {
return z.toJSONSchema(schema, options) as Record<string, unknown>;
// 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<string, unknown>;
return Object.keys(rest).length > 0 ? rest : undefined;
},
has(s: AnySchema) {
return globalReg.has(s);
},
_idmap: new Map<string, AnySchema>(),
_map: (globalReg as unknown as { _map: WeakMap<object, unknown> })._map
};
return z.toJSONSchema(schema, {
...options,
reused: 'inline',
metadata: idStrippedRegistry as unknown as z.core.$ZodRegistry<Record<string, unknown>>
}) as Record<string, unknown>;
}

/**
Expand Down
83 changes: 83 additions & 0 deletions packages/core/test/util/schemaToJson.test.ts
Original file line number Diff line number Diff line change
@@ -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' }
});
});
});
Loading