Repository navigation
schemaToJson() produces $ref in tool inputSchema, causing LLM failures #1562
Description
Description
schemaToJson() returns JSON Schema with $ref pointers for registered types (z.globalRegistry) and recursive types (z.lazy). LLMs consuming tool inputSchema cannot resolve $ref — they treat referenced parameters as untyped and serialize objects as string literals:
Expected: "parent": {"database_id": "2275ad9e-..."}
Received: "parent": "{\"database_id\":\"2275ad9e-...\"}"
→ Server rejects: MCP error -32602: Invalid arguments: expected object, received string
This is related to #1175 (AJV failing on $ref in tool schemas) — same root cause ($ref in inputSchema), different symptom (LLM stringification vs validator error).
Reproduction
import * as z from 'zod/v4';
import { schemaToJson } from '@modelcontextprotocol/core';
const Address = z.object({ street: z.string(), city: z.string() });
z.globalRegistry.add(Address, { id: 'Address' });
console.log(JSON.stringify(schemaToJson(z.object({ home: Address, work: Address }), { io: 'input' }), null, 2));Output contains $ref instead of inline types:
{
"properties": {
"home": { "$ref": "#/$defs/Address" },
"work": { "$ref": "#/$defs/Address" }
},
"$defs": { "Address": { "type": "object", ... } }
}Context
$ref in tool schemas has always been possible — the old zod-to-json-schema library used $refStrategy: "root" by default (identity-based deduplication on second encounter of the same JS object). However, #1460's switch to z.toJSONSchema() widened the blast radius significantly: registered types produce $ref even 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 to schemaToJson() that inlines all local $ref pointers 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.
Activity
fix: inline local $ref in tool inputSchema for LLM consumption
fix: inline local $ref in tool inputSchema for LLM consumption
sstklen commentedon Feb 25, 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:
📊 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. 🦞
🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.
fix: inline $ref pointers in schemaToJson for self-contained tool sch…
fix: inline local $ref in tool inputSchema for LLM consumption
fix: inline local $ref in tool inputSchema for LLM consumption
fix: inline local $ref in tool inputSchema for LLM consumption
MukundaKatta commentedon Apr 15, 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:$refpointers in the toolinputSchemaat registration time$refleft in place with$defsentries preserved (so existing recursive schemas keep working, just degraded rather than expanded)$refpreserved per JSON Schema 2020-12 semanticsWorth 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.)
felixweinberger commentedon 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.fix: inline local $ref in tool inputSchema for LLM consumption
gogakoreli commentedon 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.fix: inline local $ref in tool inputSchema for LLM consumption