Skip to content

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

Open
gogakoreli wants to merge 4 commits 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 4 commits intomodelcontextprotocol:mainfrom
gogakoreli:fix/inline-ref-in-tool-schema

Conversation

@gogakoreli
Copy link
Copy Markdown

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
Copy Markdown

pkg-pr-new bot commented Feb 20, 2026

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1563

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: b8a39a9

@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
Copy Markdown

changeset-bot bot commented Feb 20, 2026

🦋 Changeset detected

Latest commit: b8a39a9

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

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this, appreciate the thorough work here.

  1. Please rebase as we've moved to supporting standardSchema with #1689 so you'll need to refactor your PR here a bit. dereferenceLocalRefs() should hook into standardSchemaToJsonSchema() in packages/core/src/util/standardSchema.ts.

  2. Instead of leaving $ref I think we should error when we detect cycles. There's no way to represent them and the LLMs can't handle them anyway AFAIK. The Go SDK throws "cycle detected for type X" in the same situation for example.

@gogakoreli
Copy link
Copy Markdown
Author

Thanks for the review @felixweinberger — both items addressed.

1. Rebased and hooked into standardSchemaToJsonSchema()

dereferenceLocalRefs() is a pure JSON Schema utility in schema.ts (no Zod dependency), called from standardSchemaToJsonSchema() so it's library-agnostic — covers Zod, ArkType, Valibot, and fromJsonSchema().

2. Cycles throw instead of { type: "object" }

Recursive schema detected: cycle through definition "TreeNode".
MCP tool schemas cannot contain cycles because LLMs cannot resolve $ref pointers.

Fails at tools/list time so the developer knows immediately to restructure, rather than silently degrading and causing mysterious LLM failures downstream.

Cross-SDK note: Neither the Go nor Python SDK dereferences $ref — both preserve it in tool schemas. The Go SDK's cycle error is at Go type-inference time, not JSON Schema post-processing. This fix is TS SDK-specific since Zod v4 is uniquely aggressive with $ref generation.

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() which inlines all local $ref pointers, wired
into standardSchemaToJsonSchema() so all tool schemas are self-contained
and LLM-consumable regardless of schema library.

Recursive schemas throw at tools/list time — they cannot be represented
without $ref and LLMs cannot handle them.

Fixes: modelcontextprotocol#1562
@gogakoreli gogakoreli force-pushed the fix/inline-ref-in-tool-schema branch from 35edf9b to 9b42858 Compare March 31, 2026 06:41
@km-anthropic
Copy link
Copy Markdown

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Add 3 tests covering real-world Zod v4 output patterns:
- $ref with multiple metadata siblings (.meta() pattern)
- $ref with default value sibling (.default() pattern)
- $def referencing another $def (nested registered types)

These verify the sibling merge path handles all real schema
generator output correctly. Exhaustive cross-library testing
(Zod v4, ArkType, Valibot) confirmed no generator produces
$ref with siblings containing nested $ref.
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment on lines +103 to +107
// Merge sibling keywords onto the resolved schema
if (hasSiblings && resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
return { ...(resolved as Record<string, unknown>), ...siblings };
}
return resolved;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The sibling merge path in dereferenceLocalRefs fails to filter $defs/definitions before spreading them onto the resolved schema, so $defs can survive in the output -- directly undermining the PR goal of removing all $defs from tool schemas. Fix by also destructuring those keys out of siblings: const { $ref: _ref, $defs: _defs, definitions: _definitions, ...siblings } = obj.

Extended reasoning...

Bug: $defs leaks through the sibling merge path

In packages/core/src/util/schema.ts, the resolve() function has two paths for visiting object nodes: a $ref branch and a for-loop branch. The for-loop branch explicitly skips $defs/definitions:

for (const [key, value] of Object.entries(obj)) {
    if (key === "$defs" || key === "definitions") continue; // correctly filtered
    result[key] = resolve(value, stack);
}

But the $ref branch collects siblings via:

const { $ref: _ref, ...siblings } = obj; // captures EVERY key except $ref

and later spreads them unfiltered:

return { ...(resolved as Record<string, unknown>), ...siblings }; // $defs not filtered\!

If $defs or definitions are present as sibling keys alongside $ref, they survive verbatim in the merged output.

Concrete trigger

standardSchemaToJsonSchema builds the input to dereferenceLocalRefs as:

return dereferenceLocalRefs({ type: "object", ...result });

Consider a Zod type registered in z.globalRegistry and used directly as inputSchema, whose JSON Schema output is:

{ "$ref": "#/$defs/RootType", "$defs": { "RootType": { "type": "object", "properties": { "name": { "type": "string" } } } } }

The spread produces:

{ "type": "object", "$ref": "#/$defs/RootType", "$defs": { "RootType": { ... } } }

When resolve() processes this root node:

  1. It sees $ref: "#/$defs/RootType"
  2. Destructures: siblings = { type: "object", $defs: { RootType: {...} } }
  3. Resolves the ref: resolved = { type: "object", properties: { name: { type: "string" } } }
  4. hasSiblings is true, so it returns { ...resolved, type: "object", $defs: { RootType: {...} } }
  5. $defs is present in the final output -- not removed.

Why existing code does not prevent it

The asymmetry is a simple oversight: the for-loop path was written with an explicit guard for $defs/definitions, but the sibling-merge path was not. There is no other mechanism that strips these keys after resolve() returns.

Impact

This directly violates the PR core invariant: that $defs should be absent from the tool inputSchema delivered to LLM clients. A tool whose root inputSchema is a globally-registered Zod type (a legitimate and documented Zod feature) would still expose $defs in the schema, leaving the original problem unfixed for that scenario. The MCP client receives a schema that still contains $defs, undermining the PR goal.

Fix

Simply destructure the offending keys out of siblings before they can be spread:

const { $ref: _ref, $defs: _defs, definitions: _definitions, ...siblings } = obj;

This mirrors the intent of the for-loop guard and is a one-line change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated — the trigger scenario (registered type as root producing { $ref, $defs } as siblings) doesn't happen with Zod v4. When a registered type is used as root, Zod inlines it (no $ref). When nested, the $ref is inside properties, not at the root alongside $defs.

That said, the sibling merge path now resolves all sibling values (the defensive fix from the previous comment), which would handle this if it ever occurred.

…nitions`/`$defs`

Address review feedback from PR modelcontextprotocol#1563:

1. Defensive: resolve sibling values through resolve() before merging
   onto the resolved $ref schema. No known generator triggers this
   (Zod/ArkType/Valibot only produce metadata siblings), but it makes
   the sibling-merge and object-traversal paths consistent.

2. Bug fix: only strip $defs/definitions keys at the root schema level.
   Previously the filter fired at every depth, silently dropping any
   property named 'definitions' or '$defs' from nested objects.

Tests added for both fixes.
@gogakoreli
Copy link
Copy Markdown
Author

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All previously raised issues have been addressed — LGTM.

Extended reasoning...

Overview

This PR adds dereferenceLocalRefs() (~95 lines) to inline local $ref pointers in tool inputSchema before they reach LLM clients. It hooks into standardSchemaToJsonSchema() in standardSchema.ts (2-line change) and ships 14 tests (9 unit + 5 integration).

Security risks

No security-sensitive code paths (auth, crypto, permissions) are touched. The function operates purely on JSON Schema objects in memory.

Level of scrutiny

Moderate — this is a critical code path (all Zod-based tool schemas pass through it), but the scope is well-bounded. The PR underwent multiple review rounds and the author responded with detailed investigation to each concern.

Prior feedback resolution

All three issues from prior rounds are fixed in the committed code:

  1. Sibling keywords containing nested $ref — siblings are now passed through resolve() via Object.fromEntries(Object.entries(siblings).map(([k, v]) => [k, resolve(v, stack)])).
  2. $defs leaking through sibling merge path — covered by fix #1 (resolved siblings cannot contain raw $defs).
  3. Nested property named definitions/$defs silently dropped — guarded with obj === schema so only root-level JSON Schema keyword containers are stripped.

Tests for both the property-name collision cases and the defensive sibling resolution path are present. No new bugs were identified by the bug hunting system.

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rework and pointing out the layer where the Go SDK throws on cycle detection - that's actually a good point.

Doing this on registerTool might be a bigger refactor with the current shape of the SDK, but would probably be the better place to do this 🤔 maybe worth a follow up.

Regarding throwing - on second thought a graceful degradation is probably the safer approach, to avoid completely breaking servers with cycles in their schemas that might still "work" in a degraded way currently. Instead of just replacing with object though I think it would be better we try some kind of "best effort" dereferencing. Something like:

Apologies for the back and forth here but would you be open to updating that approach here?

@felixweinberger
Copy link
Copy Markdown
Contributor

Thanks for the rework and pointing out the layer where the Go SDK throws on cycle detection - that's actually a good point.

Doing this on registerTool might be a bigger refactor with the current shape of the SDK, but would probably be the better place to do this 🤔 maybe worth a follow up.

Regarding throwing - a graceful degradation is probably the safer approach, to avoid completely breaking servers with cycles in their schemas that might still work in a degraded way currently. Instead of just replacing with object though I think it would be better we try some kind of "best effort" dereferencing. Something like:

Apologies for the back and forth here but would you be open to updating that approach here?

Filed a follow up for this to consider doing this during the registerTool step instead of during tools/list - out of scope for this PR: #1847

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

3 participants