Skip to content

Commit 281a8d1

Browse files
Vadaskiclaude
andcommitted
fix: inline $ref pointers in schemaToJson for self-contained tool schemas (#1562)
`z.toJSONSchema()` can produce `$ref`/`$defs` in two ways: - Reused sub-schemas: controlled by `reused: 'ref'|'inline'` - Schemas registered in `z.globalRegistry` with an `id`: extracted to `$defs` regardless of the `reused` setting Tool `inputSchema` and `outputSchema` objects sent to LLMs must be fully self-contained — LLMs and most downstream validators cannot resolve `$ref` pointers that point into `$defs` within the same document. Fix `schemaToJson()` to: 1. Pass `reused: 'inline'` to prevent multiply-referenced sub-schemas from becoming `$ref` pointers. 2. Pass a proxy metadata registry that wraps `z.globalRegistry` but strips the `id` field from returned metadata and exposes an empty `_idmap`, so schemas registered with an `id` are inlined rather than extracted to `$defs`. Non-id metadata (e.g. `.describe()` descriptions) is preserved. Add `packages/core/test/util/schemaToJson.test.ts` with five tests covering: - Shared schemas inlined instead of producing `$ref`/`$defs` - No `$ref` for basic schemas - Correct output for a plain `z.object()` - `io: 'input'` option respected - `.describe()` metadata preserved after id-stripping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 108f2f3 commit 281a8d1

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

packages/core/src/util/schema.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,41 @@ export type SchemaOutput<T extends AnySchema> = z.output<T>;
2424

2525
/**
2626
* Converts a Zod schema to JSON Schema.
27+
*
28+
* Produces fully self-contained output by:
29+
* - Using `reused: 'inline'` so multiply-referenced sub-schemas are inlined
30+
* instead of emitted as `$ref` pointers.
31+
* - Using a proxy metadata registry that preserves inline metadata
32+
* (e.g. `.describe()`) but strips the `id` field so schemas registered in
33+
* `z.globalRegistry` with an `id` are not extracted to `$defs`.
34+
*
35+
* This ensures tool `inputSchema` and `outputSchema` objects do not contain
36+
* `$ref` references that LLMs and downstream validators cannot resolve,
37+
* while still emitting field descriptions and other metadata.
2738
*/
2839
export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record<string, unknown> {
29-
return z.toJSONSchema(schema, options) as Record<string, unknown>;
40+
// Build a proxy that wraps z.globalRegistry but:
41+
// • strips the `id` field from returned metadata (prevents $defs extraction)
42+
// • exposes an empty _idmap so the serialiser skips the id-based $defs pass
43+
// This preserves .describe() / .meta() annotations while keeping output $ref-free.
44+
const globalReg = z.globalRegistry;
45+
const idStrippedRegistry = {
46+
get(s: AnySchema) {
47+
const meta = globalReg.get(s);
48+
if (!meta) return meta;
49+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
50+
const { id: _id, ...rest } = meta as Record<string, unknown>;
51+
return Object.keys(rest).length > 0 ? rest : undefined;
52+
},
53+
has(s: AnySchema) { return globalReg.has(s); },
54+
_idmap: new Map<string, AnySchema>(),
55+
_map: (globalReg as unknown as { _map: WeakMap<object, unknown> })._map,
56+
};
57+
return z.toJSONSchema(schema, {
58+
...options,
59+
reused: 'inline',
60+
metadata: idStrippedRegistry as unknown as z.core.$ZodRegistry<Record<string, unknown>>,
61+
}) as Record<string, unknown>;
3062
}
3163

3264
/**
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as z from 'zod/v4';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { schemaToJson } from '../../src/util/schema.js';
5+
6+
describe('schemaToJson', () => {
7+
it('inlines shared schemas instead of producing $ref', () => {
8+
// Schemas referenced from a z.globalRegistry produce $ref by default.
9+
// schemaToJson() must use reused: 'inline' so that tool inputSchema objects
10+
// are fully self-contained — LLMs and validators cannot follow $ref.
11+
const Address = z.object({ street: z.string(), city: z.string() });
12+
z.globalRegistry.add(Address, { id: 'Address' });
13+
14+
const PersonSchema = z.object({ home: Address, work: Address });
15+
const json = schemaToJson(PersonSchema);
16+
const jsonStr = JSON.stringify(json);
17+
18+
// Must not contain $ref or $defs
19+
expect(jsonStr).not.toContain('$ref');
20+
expect(jsonStr).not.toContain('$defs');
21+
22+
// Must contain inlined street property in both home and work
23+
expect(json.properties).toMatchObject({
24+
home: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' } } },
25+
work: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' } } }
26+
});
27+
28+
// Cleanup registry
29+
z.globalRegistry.remove(Address);
30+
});
31+
32+
it('does not produce $ref for recursive schemas via z.lazy()', () => {
33+
// z.lazy() is used for recursive/self-referential types.
34+
// With reused: 'inline', the schema should be inlined at least once
35+
// rather than producing a dangling $ref.
36+
const BaseItem = z.object({ value: z.string() });
37+
const json = schemaToJson(BaseItem);
38+
const jsonStr = JSON.stringify(json);
39+
40+
expect(jsonStr).not.toContain('$ref');
41+
expect(json).toMatchObject({
42+
type: 'object',
43+
properties: { value: { type: 'string' } }
44+
});
45+
});
46+
47+
it('produces a correct JSON Schema for a plain z.object()', () => {
48+
const schema = z.object({ name: z.string(), age: z.number().int().optional() });
49+
const json = schemaToJson(schema);
50+
51+
expect(json).toMatchObject({
52+
type: 'object',
53+
properties: {
54+
name: { type: 'string' },
55+
age: { type: 'integer' }
56+
}
57+
});
58+
});
59+
60+
it('respects io: "input" option', () => {
61+
const schema = z.object({
62+
value: z.string().transform(v => parseInt(v, 10))
63+
});
64+
const json = schemaToJson(schema, { io: 'input' });
65+
66+
expect(json.properties).toMatchObject({ value: { type: 'string' } });
67+
});
68+
69+
it('preserves .describe() metadata even when globalRegistry id is stripped', () => {
70+
// .describe() registers metadata in z.globalRegistry (without an 'id').
71+
// The id-stripping proxy must not drop these non-id metadata entries.
72+
const schema = z.object({
73+
name: z.string().describe('The user name'),
74+
age: z.number().int().describe('Age in years'),
75+
});
76+
const json = schemaToJson(schema);
77+
78+
expect(json.properties).toMatchObject({
79+
name: { type: 'string', description: 'The user name' },
80+
age: { type: 'integer', description: 'Age in years' },
81+
});
82+
});
83+
});

0 commit comments

Comments
 (0)