Skip to content

Comments

fix: inline local $ref in tool inputSchema for LLM consumption#1563

Open
gogakoreli wants to merge 1 commit intomodelcontextprotocol:mainfrom
gogakoreli:fix/inline-ref-in-tool-schema
Open

fix: inline local $ref in tool inputSchema for LLM consumption#1563
gogakoreli wants to merge 1 commit intomodelcontextprotocol:mainfrom
gogakoreli:fix/inline-ref-in-tool-schema

Conversation

@gogakoreli
Copy link

Inline local $ref in tool inputSchema for LLM consumption

Fixes: #1562
Related: anthropics/claude-code#18260

Problem

Tool inputSchema containing $ref pointers causes LLM failures across multiple MCP clients. LLMs cannot resolve JSON Schema $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

$ref in tool schemas has always been possible (non-Zod servers, and the old zod-to-json-schema with its default $refStrategy: "root" for identity-based deduplication). However, #1460's switch to z.toJSONSchema() significantly widened the blast radius — registered types (z.globalRegistry) now produce $ref even on first and only use, and all recursive types (z.lazy) produce $ref. The old library only triggered on the second encounter of the same JS object reference.

Confirmed across Claude Code (#18260) and Kiro CLI (independently).

Solution

Add dereferenceLocalRefs() to schemaToJson() — inlines all local $ref pointers (#/$defs/..., #/definitions/...) so tool schemas are self-contained.

export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record<string, unknown> {
    const jsonSchema = z.toJSONSchema(schema, options) as Record<string, unknown>;
    return dereferenceLocalRefs(jsonSchema);
}

~95 lines of implementation, zero external dependencies.

Behavior:

  • Local $ref → inlined, $defs/definitions stripped
  • External $ref (URLs) → left as-is
  • Recursive schemas → first occurrence inlined, cycle point becomes { type: "object" }
  • Diamond references → cached (resolved once, reused)
  • Sibling keywords alongside $ref → preserved per JSON Schema 2020-12
  • dereferenceLocalRefs is @internal — not intended as public API

Alternatives considered

Alternative Why not
Revert to zod-to-json-schema Broken with Zod v4 (produces empty schemas)
dereference-json-schema npm package Adding a dep for ~95 lines in a foundational SDK; our impl is scoped to exactly what's needed
Zod's reused: "inline" option Doesn't help — registered types always produce $ref regardless (verified)
Zod's override callback $defs aren't fully built at override time; would need two-pass generation
Document "don't use z.globalRegistry" Doesn't help non-Zod servers; globalRegistry is a legitimate Zod feature
Fix in MCP clients Doesn't scale (clients in TS, Rust, Python, Go); SDK is the single point where Zod schemas pass through

Limitations

  • Only helps Zod-based servers whose schemas go through schemaToJson(). Non-Zod servers sending raw JSON Schema with $ref are not affected — their schemas don't hit this code path.
  • Recursive schemas lose type information at the cycle point ({ type: "object" } vs the full type).

Test plan

  • pnpm lint:all passes
  • pnpm test:all passes (all existing tests + 14 new)
  • pnpm build:all passes
  • Changeset included

9 unit tests (packages/core/test/schema.test.ts) — test dereferenceLocalRefs directly with crafted JSON Schema:

  • Passthrough (no $ref), registered types, recursive types, diamond references, non-existent $def (left as-is), external $ref (left as-is), sibling keyword preservation, $ref: "#" root self-reference, registry cleanup

5 integration tests (test/integration/test/server/mcp.test.ts) — full server→client pipeline:

  • Registered types, discriminatedUnion + registry, mixed $ref + inline params (Notion repro), $ref to oneOf union, recursive types
  • Each test asserts both schema shape (no $ref) AND runtime callTool round-trip

All integration tests clean up z.globalRegistry via afterEach.

Files changed

File Change
packages/core/src/util/schema.ts Add dereferenceLocalRefs(), modify schemaToJson() to call it
packages/core/test/schema.test.ts New — 9 unit tests
test/integration/test/server/mcp.test.ts Add 5 integration tests in new describe block
.changeset/inline-ref-in-tool-schema.md Changeset

@gogakoreli gogakoreli requested a review from a team as a code owner February 20, 2026 21:04
@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 20, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1563

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1563

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1563

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1563

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1563

commit: cc0d216

Tool schemas containing $ref cause LLM failures across multiple MCP clients.
LLMs cannot resolve JSON Schema $ref pointers — they serialize referenced
parameters as strings instead of objects.

While $ref was always possible in tool schemas, modelcontextprotocol#1460's switch from
zod-to-json-schema to z.toJSONSchema() widened the blast radius: registered
types (z.globalRegistry) and recursive types (z.lazy) now produce $ref on
common patterns that previously rarely triggered it.

Adds dereferenceLocalRefs() to schemaToJson() which inlines all local $ref
pointers, ensuring tool schemas are self-contained and LLM-consumable.

Fixes: modelcontextprotocol#1562
@gogakoreli gogakoreli force-pushed the fix/inline-ref-in-tool-schema branch from cc0d216 to 925ab05 Compare February 20, 2026 21:06
@changeset-bot
Copy link

changeset-bot bot commented Feb 20, 2026

🦋 Changeset detected

Latest commit: 925ab05

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gogakoreli
Copy link
Author

I also have a detailed ADR documenting the investigation, alternatives analysis, and design rationale. Happy to include it as docs/adrs/0001-dereference-ref-in-tool-input-schema.md if that's useful — left it out to keep the PR focused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant