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.
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.