Repository navigation
schemaToJson() produces $ref in tool inputSchema, causing LLM failures #1562
Description
Activity
- added 2 commits that reference this issue
on Feb 20, 2026 Hey! I ran into a similar pattern in our bug knowledge base and thought this might help.
What's happening: schemaToJson() (introduced via #1460's switch to Zod v4's built-in z.toJSONSchema()) emits $ref pointers for types registered in z.globalRegistry and for recursive z.lazy schemas. LLMs consuming tool inputSchema have no JSON Schema $ref resolver — they see {"$ref": "#/$defs/Address"} as an opaque string-typed field, so they serialize the object value as a JSON string literal instead of a nested object. The MCP server's argument validator then rejects it: 'expected object, received string'.
What worked for us:
Post-process the output of schemaToJson() (or z.toJSONSchema()) to inline/dereference all $ref pointers before returning the tool's inputSchema. Walk the schema tree, replace every {"$ref": "#/$defs/Foo"} node with the actual definition from $defs, then strip the now-unused $defs block. For recursive schemas, set a max-depth cutoff to avoid infinite expansion. Alternatively, pass a Zod v4 option to suppress $ref emission (if available), or reintroduce zod-to-json-schema with {$refStrategy: 'none'} for tool schema generation specifically.
Steps:
- Create a dereferenceSchema(schema) utility that recursively walks JSON Schema and replaces {"$ref": "#/$defs/X"} nodes with the inlined definition from schema.$defs[X]
- Handle recursive $ref cycles by tracking visited refs and capping expansion depth (e.g., 3 levels) to prevent infinite loops
- Call dereferenceSchema() on the output of schemaToJson() inside the tool registration / listTools handler before returning inputSchema to the client
- After inlining, delete the top-level $defs key since all references are now resolved inline
- Add tests: (a) z.globalRegistry registered type produces inline schema without $ref, (b) z.lazy recursive type expands to max depth without $ref, (c) LLM-style consumer can parse the resulting schema correctly
// src/utils/dereference-schema.ts export function dereferenceSchema(schema: Record<string, any>, maxDepth = 10): Record<string, any> { const defs = schema.$defs || schema.definitions || {}; function resolve(node: any, depth: number, visiting: Set<string>): any { if (!node || typeof node !== 'object' || depth > maxDepth) return node; if (Array.isArray(node)) return node.map(n => resolve(n, depth, visiting)); if (node.$ref && typeof node.$ref === 'string') { const match = node.$ref.match(/^#\/\$defs\/(.+)$/) || node.$ref.match(/^#\/definitions\/(.+)$/); if (match && defs[match[1]]) { if (visiting.has(match[1])) return {}; // break cycle visiting.add(match[1]); const inlined = resolve(structuredClone(defs[match[1]]), depth + 1, visiting); visiting.delete(match[1]); return inlined; } return node; } const result: Record<string, any> = {}; for (const [key, value] of Object.entries(node)) { if (key === '$defs' || key === 'definitions') continue; result[key] = resolve(value, depth, visiting); } return result; } return resolve(schema, 0, new Set()); } // Usage in tool schema generation: // const rawSchema = schemaToJson(zodSchema, { io: 'input' }); // const inputSchema = dereferenceSchema(rawSchema); // return { name, description, inputSchema };
📊 We found 1 similar case in our knowledge base with the same pattern — this gives us medium confidence in this analysis.
Hope this helps! Let me know if it doesn't match your case — happy to dig deeper. 🦞
Disclosure: This analysis is from Confucius Debug, an AI-powered community KB for agent bugs. Please verify before applying.
🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.
- added a commit that references this issue
on Mar 12, 2026 - added 3 commits that reference this issue
on Mar 31, 2026 Confirming this is the same root cause on the Python SDK side — pydantic's
model_json_schema()emits the same$ref/$defsshape and downstream LLM clients fail the exact way described here. Just opened modelcontextprotocol/python-sdk#2448 to fix it on the Python side, mirroring the approach in #1563:- Inline local
$refpointers in the toolinputSchemaat registration time - Cache resolved defs (diamond references resolve once)
- Cycles handled gracefully — cyclic
$refleft in place with$defsentries preserved (so existing recursive schemas keep working, just degraded rather than expanded) - Sibling keywords alongside
$refpreserved per JSON Schema 2020-12 semantics
Worth landing #1563 + #2448 together for consistent cross-SDK behavior — otherwise downstream MCP clients see different schema shapes depending on whether the server is Python or TypeScript, which makes "supports any MCP server" a false claim for tool-heavy servers.
(Side note: #1175 mentioned in the OP is a slightly different beast — AJV-side validator error rather than LLM-side stringification — but the inline-refs fix here would close it as a downstream consequence.)
- Inline local
bug reproduced on both
main(b8886e7) andv1.x(bf1e022).standardSchemaToJsonSchema()returns$refpointers that LLMs cannot resolve, causingtools/callto fail with-32602: expected object, received stringfor any schema usingz.globalRegistryorz.lazy.workaround: avoid registering Zod types in
z.globalRegistryand avoidz.lazyin tool input schemas.the fix is a
dereferenceLocalRefs()step instandardSchemaToJsonSchema()(packages/core/src/util/standardSchema.ts:151): walk the schema tree, inline each{"$ref": "#/$defs/X"}node with the actual definition from$defs, then strip the top-level$defsblock. cycle detection (trackingvisitingrefs) handlesz.lazyrecursive types by leaving the back-reference$refin place rather than expanding infinitely.repro script, command, and output
repro.ts (run from repo root with
npx tsx repro.ts):import * as z from 'zod'; import { standardSchemaToJsonSchema } from './packages/core/src/util/standardSchema.ts'; console.log('=== Test 1: z.globalRegistry registered type ==='); const Address = z.object({ street: z.string(), city: z.string() }); z.globalRegistry.add(Address, { id: 'Address' }); const schema1 = z.object({ home: Address, work: Address }); const result1 = standardSchemaToJsonSchema(schema1, 'input'); const json1 = JSON.stringify(result1, null, 2); console.log(json1); const hasRef1 = json1.includes('$ref'); console.log('\n$ref present:', hasRef1, hasRef1 ? '(BUG)' : '(OK)'); console.log('\n=== Test 2: z.lazy recursive type ==='); type Node = { value: string; child?: Node }; const NodeSchema: z.ZodType<Node> = z.object({ value: z.string(), child: z.lazy(() => NodeSchema).optional(), }); const schema2 = z.object({ root: NodeSchema }); const result2 = standardSchemaToJsonSchema(schema2, 'input'); const json2 = JSON.stringify(result2, null, 2); console.log(json2);
command:
npx tsx repro.tsoutput (before fix):
=== Test 1: z.globalRegistry registered type === { "type": "object", "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "home": { "$ref": "#/$defs/Address" }, "work": { "$ref": "#/$defs/Address" } }, "$defs": { "Address": { "type": "object", ... } } } $ref present: true (BUG)code path
standardSchemaToJsonSchema()atpackages/core/src/util/standardSchema.ts:151callsschema['~standard'].jsonSchema.input({ target: 'draft-2020-12' })— this is Zod v4'sz.toJSONSchema()via the Standard Schema interface.- Zod v4 emits
$reffor any type inz.globalRegistry(regardless of whether it appears once or many times) and for allz.lazynodes. - The function returns
{ type: 'object', ...result }with no dereferencing step. - This schema is served verbatim as
tools/listinputSchema; LLMs see{"$ref": "..."}as opaque and serialize the field as a JSON string literal instead of a nested object.
fix: add
dereferenceLocalRefs()before the return instandardSchemaToJsonSchema(). single-file change, no public API change. present on bothmainandv1.x.suggested fix
// packages/core/src/util/standardSchema.ts +function dereferenceLocalRefs(schema: Record<string, unknown>): Record<string, unknown> { + const defs = (schema.$defs ?? schema.definitions ?? {}) as Record<string, unknown>; + const preservedDefs = new Set<string>(); + + function resolve(node: unknown, visiting: Set<string>): unknown { + if (node === null || typeof node !== 'object') return node; + if (Array.isArray(node)) return node.map(item => resolve(item, visiting)); + const obj = node as Record<string, unknown>; + if (typeof obj.$ref === 'string') { + const match = /^#\/\$defs\/(.+)$/.exec(obj.$ref) ?? /^#\/definitions\/(.+)$/.exec(obj.$ref); + if (match) { + const name = match[1]; + if (visiting.has(name)) { preservedDefs.add(name); return obj; } + const def = defs[name]; + if (def !== undefined) { + visiting.add(name); + const inlined = resolve(def, visiting); + visiting.delete(name); + return inlined; + } + } + return obj; + } + const result: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === '$defs' || key === 'definitions') continue; + result[key] = resolve(value, visiting); + } + return result; + } + + const resolved = resolve(schema, new Set()) as Record<string, unknown>; + if (preservedDefs.size > 0) { + const keptDefs: Record<string, unknown> = {}; + for (const name of preservedDefs) keptDefs[name] = defs[name]; + resolved.$defs = keptDefs; + } + return resolved; +} export function standardSchemaToJsonSchema(...): Record<string, unknown> { const result = schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); ... - return { type: 'object', ...result }; + return dereferenceLocalRefs({ type: 'object', ...result }); }
test to verify: assert that
standardSchemaToJsonSchemaoutput contains no$refstrings when the input schema uses a type registered inz.globalRegistry, and thatz.lazyrecursive schemas inline correctly with cycle-safe$defspreservation.- addedbugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working onfix proposedBot has a verified fix diff in the commentBot has a verified fix diff in the commentP0Broken core functionality, security issues, critical missing featureBroken core functionality, security issues, critical missing feature
on Apr 16, 2026 - addedP1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested featureand removedP0Broken core functionality, security issues, critical missing featureBroken core functionality, security issues, critical missing feature
on Aug 17, 2026 Re-triaging P0 → P1: the P0 was bot-applied over the earlier triage; the emitted
$ref/$defsis spec-valid JSON Schema and only affectsz.globalRegistry/z.lazyschemas (client-side resolution), so this is a significant interop bug, not broken core functionality — fix is #1563, needs a rebase ontopackages/core-internal.- added a commit that references this issue
on Aug 26, 2026 @felixweinberger #1563 is rebased onto
packages/core-internalas requested — all suites green, including conformance (json-schema-2020-12passes: hand-authored schemas viafromJsonSchema()keep$ref/$defsverbatim per SEP-1613; only library-converted schemas get inlined). Details in the PR. Ready for review.- added a commit that references this issue
on Oct 5, 2026
Description
schemaToJson()returns JSON Schema with$refpointers for registered types (z.globalRegistry) and recursive types (z.lazy). LLMs consuming toolinputSchemacannot resolve$ref— they treat referenced parameters as untyped and serialize objects as string literals:This is related to #1175 (AJV failing on
$refin tool schemas) — same root cause ($refininputSchema), different symptom (LLM stringification vs validator error).Reproduction
Output contains
$refinstead of inline types:{ "properties": { "home": { "$ref": "#/$defs/Address" }, "work": { "$ref": "#/$defs/Address" } }, "$defs": { "Address": { "type": "object", ... } } }Context
$refin tool schemas has always been possible — the oldzod-to-json-schemalibrary used$refStrategy: "root"by default (identity-based deduplication on second encounter of the same JS object). However, #1460's switch toz.toJSONSchema()widened the blast radius significantly: registered types produce$refeven on first and only use, and all recursive types produce$ref.Confirmed across Claude Code (anthropics/claude-code#18260) and Kiro CLI (independently).
Proposed fix
Add a
dereferenceLocalRefs()step toschemaToJson()that inlines all local$refpointers and strips$defs/definitions. ~95 lines, zero external dependencies.I already have a working implementation with tests — wanted to file the issue for discussion before submitting the PR per contributing guidelines. Happy to submit if this approach looks right.