Skip to content

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

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.

Activity

  1. added 2 commits that reference this issue on Feb 20, 2026
    cc0d216
    925ab05
  2. sstklen commented on Feb 25, 2026

    @sstklen

    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:

    1. Create a dereferenceSchema(schema) utility that recursively walks JSON Schema and replaces {"$ref": "#/$defs/X"} nodes with the inlined definition from schema.$defs[X]
    2. Handle recursive $ref cycles by tracking visited refs and capping expansion depth (e.g., 3 levels) to prevent infinite loops
    3. Call dereferenceSchema() on the output of schemaToJson() inside the tool registration / listTools handler before returning inputSchema to the client
    4. After inlining, delete the top-level $defs key since all references are now resolved inline
    5. 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.

  3. added 3 commits that reference this issue on Mar 31, 2026
    e7895ce
    9b42858
    dc5055f
  4. MukundaKatta commented on Apr 15, 2026

    @MukundaKatta

    Confirming this is the same root cause on the Python SDK side — pydantic's model_json_schema() emits the same $ref/$defs shape 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 $ref pointers in the tool inputSchema at registration time
    • Cache resolved defs (diamond references resolve once)
    • Cycles handled gracefully — cyclic $ref left in place with $defs entries preserved (so existing recursive schemas keep working, just degraded rather than expanded)
    • Sibling keywords alongside $ref preserved 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.)

  5. mcp-claude commented on Apr 16, 2026

    @mcp-claude

    bug reproduced on both main (b8886e7) and v1.x (bf1e022). standardSchemaToJsonSchema() returns $ref pointers that LLMs cannot resolve, causing tools/call to fail with -32602: expected object, received string for any schema using z.globalRegistry or z.lazy.

    workaround: avoid registering Zod types in z.globalRegistry and avoid z.lazy in tool input schemas.

    the fix is a dereferenceLocalRefs() step in standardSchemaToJsonSchema() (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 $defs block. cycle detection (tracking visiting refs) handles z.lazy recursive types by leaving the back-reference $ref in 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.ts

    output (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
    1. standardSchemaToJsonSchema() at packages/core/src/util/standardSchema.ts:151 calls schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) — this is Zod v4's z.toJSONSchema() via the Standard Schema interface.
    2. Zod v4 emits $ref for any type in z.globalRegistry (regardless of whether it appears once or many times) and for all z.lazy nodes.
    3. The function returns { type: 'object', ...result } with no dereferencing step.
    4. This schema is served verbatim as tools/list inputSchema; 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 in standardSchemaToJsonSchema(). single-file change, no public API change. present on both main and v1.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 standardSchemaToJsonSchema output contains no $ref strings when the input schema uses a type registered in z.globalRegistry, and that z.lazy recursive schemas inline correctly with cycle-safe $defs preservation.

  6. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    fix proposedBot has a verified fix diff in the comment
    P0Broken core functionality, security issues, critical missing feature
    on Apr 16, 2026
  7. added
    P1Significant bug affecting many users, highly requested feature
    and removed
    P0Broken core functionality, security issues, critical missing feature
    on Aug 17, 2026
  8. felixweinberger commented on Aug 17, 2026

    @felixweinberger
    Contributor

    Re-triaging P0 → P1: the P0 was bot-applied over the earlier triage; the emitted $ref/$defs is spec-valid JSON Schema and only affects z.globalRegistry/z.lazy schemas (client-side resolution), so this is a significant interop bug, not broken core functionality — fix is #1563, needs a rebase onto packages/core-internal.

  9. added a commit that references this issue on Aug 26, 2026
    45d2ba1
  10. gogakoreli commented on Aug 26, 2026

    @gogakoreli
    Author

    @felixweinberger #1563 is rebased onto packages/core-internal as requested — all suites green, including conformance (json-schema-2020-12 passes: hand-authored schemas via fromJsonSchema() keep $ref/$defs verbatim per SEP-1613; only library-converted schemas get inlined). Details in the PR. Ready for review.

  11. added a commit that references this issue on Oct 5, 2026
    760dec4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many users, highly requested featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions