Skip to content

schemaToJson() produces $ref in tool inputSchema, causing LLM failures #1562

@gogakoreli

Description

@gogakoreli

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions