Skip to content

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

Open
gogakoreli wants to merge 8 commits into
modelcontextprotocol:mainfrom
gogakoreli:fix/inline-ref-in-tool-schema
Open

fix: inline local $ref in tool inputSchema for LLM consumption#1563
gogakoreli wants to merge 8 commits into
modelcontextprotocol: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

pkg-pr-new Bot commented Feb 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@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: 290ebc6

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

changeset-bot Bot commented Feb 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 290ebc6

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

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy 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.

@felixweinberger felixweinberger left a comment

Copy link
Copy Markdown
Contributor

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.

@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

Comment thread packages/core/src/util/schema.ts Outdated
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment thread packages/core/src/util/schema.ts Outdated
Comment thread packages/core-internal/src/util/schema.ts
gogakoreli added a commit to gogakoreli/typescript-sdk that referenced this pull request Apr 1, 2026
…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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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.

@felixweinberger felixweinberger left a comment

Copy link
Copy Markdown
Contributor

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

@gogakoreli
gogakoreli force-pushed the fix/inline-ref-in-tool-schema branch from b8a39a9 to c7b353e Compare April 2, 2026 19:22
ravyg added a commit to ravyg/typescript-sdk that referenced this pull request Apr 8, 2026
Currently `standardSchemaToJsonSchema()` is called lazily inside the
`tools/list` request handler, re-converting every tool's schema on every
list request. The same applies to prompts via
`promptArgumentsFromStandardSchema()` in the `prompts/list` handler.

Move the conversion to `_createRegisteredTool()` /
`_createRegisteredPrompt()` and cache the result on `RegisteredTool`
(`inputJsonSchema`, `outputJsonSchema`) and `RegisteredPrompt`
(`cachedArguments`). The list handlers now read from these cached fields.
The `update()` methods recompute the cache when schemas change.

This:
- Surfaces schema conversion errors (e.g. cycle detection from modelcontextprotocol#1563) at
  dev time when the tool is registered, not at runtime when a client first
  calls `tools/list`
- Avoids re-converting identical schemas on every `tools/list` /
  `prompts/list` call
- Matches the Go SDK and FastMCP, which both process schemas at
  registration time

Includes regression tests verifying eager conversion at registration,
cached reuse across list calls, and cache invalidation on `update()`
for both tools and prompts.

Fixes modelcontextprotocol#1847
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment thread packages/core/src/util/schema.ts Outdated
Comment thread .changeset/inline-ref-in-tool-schema.md Outdated
@gogakoreli
gogakoreli force-pushed the fix/inline-ref-in-tool-schema branch from 7d22a03 to 7ef2c04 Compare April 12, 2026 20:28
@gogakoreli

Copy link
Copy Markdown
Author

Both items addressed in the latest push:

Changeset — Updated to accurately describe the graceful degradation behavior (cyclic $ref left in place with $defs preserved, non-cyclic refs fully inlined). Good catch — this was stale from the earlier throw-on-cycles iteration.

Boolean $defs + sibling merge — Documented as a known limitation with a code comment at the guard and a unit test asserting the behavior. The SDK's JsonSchemaType explicitly excludes boolean schemas by design (validators/types.ts), and no schema library produces boolean $defs entries (Zod: z.any(){}, z.never(){not:{}}). Not worth adding code complexity for an architecturally unreachable input.

@gogakoreli

Copy link
Copy Markdown
Author

cc @felixweinberger, ready for re-review

LeeHampton added a commit to timescale/mcp-boilerplate-node that referenced this pull request Jul 24, 2026
zod-to-json-schema emits $ref pointers when a Zod schema instance is
reused across multiple fields. Several LLM providers (e.g. Moonshot/Kimi K3)
reject $ref values that don't start with "#/$defs/", breaking tool use
for any MCP server that shares a Zod singleton across parameters.

These factory functions return a fresh instance on every call so the
generated JSON Schema is always inlined — zero $ref references.
Downstream MCP servers can import them instead of defining their own,
avoiding a class of compatibility bugs that are hard to diagnose.

See: modelcontextprotocol/typescript-sdk#1563
     modelcontextprotocol/typescript-sdk#2100
LeeHampton added a commit to timescale/mcp-boilerplate-node that referenced this pull request Jul 24, 2026
zod-to-json-schema emits $ref pointers when a Zod schema instance is
reused across multiple fields. Several LLM providers (e.g. Moonshot/Kimi K3)
reject $ref values that don't start with "#/$defs/", breaking tool use
for any MCP server that shares a Zod singleton across parameters.

These factory functions return a fresh instance on every call so the
generated JSON Schema is always inlined — zero $ref references.
Downstream MCP servers can import them instead of defining their own,
avoiding a class of compatibility bugs that are hard to diagnose.

See: modelcontextprotocol/typescript-sdk#1563
     modelcontextprotocol/typescript-sdk#2100
gregsaab pushed a commit to timescale/mcp-boilerplate-node that referenced this pull request Jul 24, 2026
zod-to-json-schema emits pointers when a Zod schema instance is reused
across multiple fields. Several LLM providers (e.g. Moonshot/Kimi K3)
reject values that don't start with "#/\/", breaking tool use for any
MCP server that shares a Zod singleton across parameters.

These factory functions return a fresh instance on every call so the
generated JSON Schema is always inlined — zero references. Downstream
MCP servers can import them instead of defining their own, avoiding a
class of compatibility bugs that are hard to diagnose.

See: modelcontextprotocol/typescript-sdk#1563
     modelcontextprotocol/typescript-sdk#2100

We initially took at a stab at fixing a very specific issue we saw
around the billing MCP in this PR
timescale/tiger-billing-mcp-server#7

But changing it here will get ahead of any other MCPs that end up
implementing a similar pattern. This does not however change any future
schema changes that result in references. If it becomes an issue we
might have to think of a broader fix.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
gogakoreli and others added 6 commits August 26, 2026 14:17
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
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.
…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.
- Cyclic $ref left in place with $defs preserved (no throw)
- Non-cyclic refs still fully inlined
- Single-pass cycle tracking via cyclicDefs Set
- resolve() -> inlineRefs() with JSDoc
- Regex -> startsWith + slice
- Preserved $defs use cached resolved versions (fixes dangling $ref bug)
- Tests rewritten as declarative toEqual on full expected output
- Added multi-hop cycle test with shuffled $defs order
- ADR updated with review traceability and test philosophy
…nceLocalRefs

standardSchemaToJsonSchema now inlines local $ref and strips $defs before
elicitation's restricted-schema validation runs, so a schema that previously
failed the root-keyword check now converts to a valid wire schema. Update the
test to assert the inlined result instead of the rejection.
@gogakoreli
gogakoreli force-pushed the fix/inline-ref-in-tool-schema branch from 9674406 to 4fd247b Compare August 26, 2026 21:51
@gogakoreli

Copy link
Copy Markdown
Author

@felixweinberger Rebased onto packages/core-internal as requested — ready for review.

What moved:

  • dereferenceLocalRefs()packages/core-internal/src/util/schema.ts, hooked into standardSchemaToJsonSchema() at the same insertion point (covers all conversion paths: ~standard.jsonSchema, the zod 4.0–4.1 fallback, and every consumer — tools, prompts, elicitation)
  • Unit tests → packages/core-internal/test/util/schema.test.ts
  • Changeset updated for the new package layout (core-internal/client/server)

One behavioral note surfaced by the rebase: the fix now also reaches elicitation schemas. A hand-built ~standard schema using $defs/$ref previously failed elicitation's root-keyword validation; it now converts cleanly to the restricted wire schema (refs inlined, $defs stripped). Updated inputRequired.test.ts to assert the converted result instead of the rejection — this is a strict improvement, registry-typed elicitation schemas now just work.

Clarification on cycle handling (the thread history is confusing on this): an earlier revision threw on cycles; the final implementation gracefully degrades instead — cyclic $ref are left in place with only their $defs entries preserved, while all non-cyclic refs are fully inlined. This avoids breaking existing servers with recursive schemas that work (degraded) today. The changeset describes the current behavior.

Verification on the rebased branch:

  • core-internal: 1459/1459 tests, typecheck + lint clean
  • server: 481/481
  • test-integration: 376/376 (incl. the 5 end-to-end $ref pipeline tests)
  • full workspace build passes

Re #1861: complementary, not conflicting — it moves when conversion happens (registration time); this PR fixes what the conversion emits. If #1861 lands, the cycle handling here automatically applies at registration time with zero changes.

… library-converted schemas

The json-schema-2020-12 conformance scenario (SEP-1613) requires schemas
registered via fromJsonSchema() to survive tools/list verbatim, including
$defs/$ref/$anchor. Skip dereferenceLocalRefs for vendor 'mcp' — those
schemas are authorial intent. Library-converted schemas (Zod, ArkType,
Valibot) still get local $ref inlined, since their $ref is a conversion
artifact LLMs cannot resolve.

Adds regression tests for both sides of the split.
@gogakoreli

gogakoreli commented Aug 26, 2026

Copy link
Copy Markdown
Author

Fixed the json-schema-2020-12 conformance failure — it was a real interaction with this PR, and the fix makes the design more correct:

That scenario (SEP-1613) requires hand-authored schemas registered via fromJsonSchema() to survive tools/list verbatim, $defs/$ref/$anchor included. Dereferencing was rewriting them. The split is now explicit in standardSchemaToJsonSchema():

  • vendor 'mcp' (fromJsonSchema() — hand-authored): passed through untouched. The author wrote $ref on purpose; SEP-1613 says it must round-trip.
  • Library-converted (Zod, ArkType, Valibot): local $ref inlined. There the $ref is a conversion artifact (z.globalRegistry, z.lazy) the author never wrote and LLMs can't resolve.

This also lands exactly on the issue's original scoping — the fix targets schemas where $ref appears as a conversion side effect, not raw JSON Schema servers.

Added regression tests for both sides. Full local run: conformance server suite green (only pre-existing baselined tasks-* failures), core-internal 1461/1461, server 481/481, integration 376/376, typecheck + lint + build clean. Ready for review.

@KKonstantinov

Copy link
Copy Markdown
Contributor

@claude review

@KKonstantinov KKonstantinov self-assigned this Sep 12, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked: the new ./schema import in standardSchema.ts does not create a cycle (schema.ts imports nothing from standardSchema.ts); outputSchema goes through the same standardSchemaToJsonSchema path in mcp.ts, so input and output schemas are inlined symmetrically; and dereferenceLocalRefs is not re-exported from core-internal/public or the client/server package barrels.

Extended reasoning...

Findings are being posted inline, so approval is off the table; a human reviewer also still has a CHANGES_REQUESTED state on this PR. This note only records the additional concerns examined and ruled out on this revision: no circular import between the two util modules, symmetric handling of input and output schemas (the ruled-out candidate that outputSchema kept $ref does not hold since mcp.ts:202 and :1338 call the same converter), and the @ internal helper staying off the public API surface.

2 verified lower-impact observations (convention, logging or cleanup points) were not posted.

Comment on lines +89 to +118
if (def === undefined) return obj; // Unknown def — leave as-is
if (stack.has(defName)) {
cyclicDefs.add(defName);
return obj; // Cycle — leave $ref in place
}

if (resolvedDefs.has(defName)) {
resolved = resolvedDefs.get(defName);
} else {
stack.add(defName);
resolved = inlineRefs(def, stack);
stack.delete(defName);
resolvedDefs.set(defName, resolved);
}

// Merge sibling keywords onto the resolved definition.
// Note: boolean JSON Schemas (true/false) skip this merge — siblings are dropped.
// This is acceptable: the SDK's JsonSchemaType excludes boolean schemas by design,
// and no schema library (Zod v4, ArkType, Valibot) produces boolean $defs entries.
if (hasSiblings && resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
const resolvedSiblings = Object.fromEntries(Object.entries(siblings).map(([k, v]) => [k, inlineRefs(v, stack)]));
return { ...(resolved as Record<string, unknown>), ...resolvedSiblings };
}
return resolved;
}

// Regular object — recurse into values, skipping root-level $defs container
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (obj === schema && (key === '$defs' || key === 'definitions')) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) A local $ref that the walker cannot resolve (a JSON-pointer into a def such as #/$defs/Foo/properties/bar, or a #/definitions/X ref when $defs is also present and wins) is left in place, yet the root $defs/definitions container is still stripped at line 118, so tools/list now advertises a dangling $ref where the base emitted a resolvable schema and clients that compile inputSchema (Ajv MissingRefError) fail. Fix: only strip a container (or an entry) once every ref into it has been inlined — e.g. track unresolved local refs the way cyclicDefs is tracked and keep both containers' referenced entries, or skip stripping entirely when any #/$defs//#/definitions/ ref survives. [also at: packages/core-internal/src/util/schema.ts:121 - When a library-converted schema carries both $defs and definitions, only #/$defs/ refs are resolved but the root loop strips BOTH containers, so #/definitions/X refs (left as-is by the prefix check) are advertised dangling and the server's own Ajv validation of that tool's arguments throws…; packages/core-internal/src/util/schema.ts:89 - A local $ref the resolver declines to inline (JSON-pointer-escaped or percent-encoded def name such as…; +1 more]

Extended reasoning...

Condition: a non-mcp StandardJSONSchemaV1 converter whose output contains a local ref the helper cannot look up by bare def name — I could not enumerate every library's emitted pointer forms, so this is conditional. Path: defsKey (line 44) picks one container; defs[defName] at line 89 is a flat lookup, so #/$defs/Foo/properties/bar or #/definitions/X (when $defs exists) returns obj untouched. Later the regular-object loop at line 118 unconditionally drops both $defs and definitions from the root, and the re-attach block only restores names in cyclicDefs. Result on the wire: { ..., properties: { x: { $ref: '#/$defs/Foo/properties/bar' } } } with no…

Verification: nit — triggers only when a non-'mcp' StandardJSONSchemaV1 converter emits a local $ref that is not a bare top-level def name (a deep JSON pointer such as #/$defs/Foo/properties/bar, a JSON-pointer-escaped/percent-encoded name, or #/definitions/X while a root $defs also exists); the mechanism is real in the code but I could not name any library the repo targets that produces such refs.…

Comment on lines +108 to +110
if (hasSiblings && resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
const resolvedSiblings = Object.fromEntries(Object.entries(siblings).map(([k, v]) => [k, inlineRefs(v, stack)]));
return { ...(resolved as Record<string, unknown>), ...resolvedSiblings };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) The sibling merge at schema.ts:110 spreads the $ref node's siblings OVER the resolved definition, so a node like { $ref:'#/$defs/Base', properties:{extra:...}, required:['extra'] } loses Base's own properties/required entirely — the advertised inputSchema silently drops constraints that base (which left $ref for the validator to conjoin) enforced, and the LLM no longer sees Base's fields. Fix: merge structural keywords conjunctively (emit allOf:[resolved, siblings] when a sibling key collides with a key of the resolved def, or deep-merge properties/required) and only shallow-override annotation keys (description/title/default/examples); the same rule applies to the root-level $ref case where type and other root keys are merged.

Extended reasoning...

JSON Schema 2020-12 defines $ref as an in-place applicator that is conjoined with its siblings (core §8.2.3.1); Ajv and cfworker both validate $ref AND siblings. On base the schema went out untouched, so a validator saw both Base's required:['id'] and the sibling required:['extra']. After merge: :77 splits {$ref, ...siblings}; :99 resolves Base to {type:'object', properties:{id:...}, required:['id']}; :110 returns {...resolved, ...resolvedSiblings} -> properties and required are replaced by the sibling values -> id is gone from both properties and required. Consequence: the client's Ajv-based argument validation and the server's memoized inputSchema (mcp.ts:109) now accept {extra:1} without id, and the model is never told about id, so tools/call reaches the handler and fails in Zod parsing with a confusing error, or worse passes an unvalidated shape. The finder dismissed this because Zod's own flattenRef uses override semantics and 'no supported library emits structural applicator siblings' — but this path runs for ANY Standard Schema vendor except 'mcp'…

Verification: nit — triggers when a non-'mcp' vendor schema (a custom Standard JSON Schema adapter, or a JSON-Schema-native library) yields a $ref node carrying structural siblings such as properties/required; known converters (Zod v4 derives $ref siblings only from metadata like description/default/readOnly because a schema with _zod.parent never generates its own body) do not produce this shape,…

Comment on lines +44 to +45
const defsKey = '$defs' in schema ? '$defs' : 'definitions' in schema ? 'definitions' : undefined;
const defs: Record<string, unknown> = defsKey ? (schema[defsKey] as Record<string, unknown>) : {};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) If any non-'mcp' Standard Schema converter returns $defs (or definitions) as null/a non-object, schema.ts:44-45 selects it as the defs container without a type check and the first local $ref dereferences defs[defName] on null at :88, so tools/list (mcp.ts:189, uncached, every request) throws a TypeError and returns a JSON-RPC error for the whole tool list, where base passed the schema through untouched. Fix: only treat $defs/definitions as a container when it is a non-null, non-array object (fall back to returning schema unchanged otherwise), so malformed converter output degrades to the base behaviour instead of a 500.

Extended reasoning...

Path: standardSchemaToJsonSchema :242 -> dereferenceLocalRefs. :44 defsKey = '$defs' in schema ? '$defs' : ... is a key-presence test, so {$defs: null, properties:{a:{$ref:'#/$defs/A'}}} selects '$defs'; :45 casts null to Record without checking; :50 does not return because defsKey is set; walk reaches {$ref} at :78 -> prefix matches -> :88 defs['A'] -> TypeError: Cannot read properties of null. Consumer: mcp.ts:189 runs this inside the tools/list handler on every request (no memo) so the exception becomes a JSON-RPC internal error for tools/list; registerTool's eager conversion at :831 swallows it per its warn-never-throw contract, so registration appears to succeed and the failure only surfaces at list time. Base: { type:'object', ...result } returned as-is, list succeeds. Population (external trigger, conditional): any StandardJSONSchemaV1 vendor other than 'mcp' whose ~standard.jsonSchema.input() emits a null/empty-literal defs slot — the mcp guard at standardSchema.ts:239 does not protect ArkType/Valibot/third-party converters, and five finders independently…

Verification: nit — triggers only when a non-'mcp' StandardJSONSchemaV1 converter returns $defs (or definitions) as null/undefined while also emitting a #/$defs/X local $ref (a self-contradictory schema no known library — Zod v4, ArkType, Valibot — produces; realistically only a custom or buggy third-party ~standard.jsonSchema implementation). Mechanism verified in… | nit. Trigger: a non-'mcp'…

Comment on lines +95 to +101
if (resolvedDefs.has(defName)) {
resolved = resolvedDefs.get(defName);
} else {
stack.add(defName);
resolved = inlineRefs(def, stack);
stack.delete(defName);
resolvedDefs.set(defName, resolved);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Servers whose Zod schemas use z.globalRegistry/.meta({id}) types that reference each other (OpenAPI-derived graphs) now ship a tools/list payload that grows with the product of fan-outs (N^D) instead of one $defs copy per type as on the base, with no size cap and no opt-out — a per-session cost paid by every client and every LLM prompt that embeds the tool list. Fix: bound the expansion (e.g. inline only up to a byte/depth budget or a per-def use count and leave $ref+$defs beyond it), or expose an opt-out so large registered-type graphs keep the linear form. The dismissal's 'pre_existing' basis is wrong for this population: Zod's reused:'inline' never applied to registered types, which always emitted $ref on base. [also at: packages/core-internal/src/util/standardSchema.ts:242 - If a library-converted inputSchema has shared definitions referenced from several places at several nesting levels (z.globalRegistry / .meta({id}) types, common in OpenAPI-derived Zod), unbounded inlining turns the DAG into a tree whose serialized size is the product of the fan-in along every path,…]

Extended reasoning...

Trigger: a tool registered with a Zod inputSchema built from registered types that fan out, e.g. Point = z.object({x,y}).meta({id:'Point'}), Range = z.object({a: Point, b: Point}).meta({id:'Range'}), Box = z.object({r1: Range, r2: Range}).meta({id:'Box'}), Doc = z.object({b1: Box, b2: Box, ...}). On the base commit (git show b654261:packages/core-internal/src/util/standardSchema.ts, the final return { type: 'object', ...result }) Zod's z.toJSONSchema emits $defs: {Point, Range, Box} once each and $ref at every use site: serialized size is linear in the number of types. After this change standardSchemaToJsonSchema (standardSchema.ts:242) calls dereferenceLocalRefs, whose cache at schema.ts:95-101 keeps the in-memory graph shared but every $ref site returns the same…

Verification: nit — conflicts with stated purpose: a size bound would leave some $ref+$defs in place, which is exactly what the PR sets out to eliminate. Trigger: a library-converted (non-mcp vendor) tool inputSchema whose registered/reused types reference each other at multiple levels with multiple uses per level (e.g. Point used 4x in Range, Range 4x in Box, Box 4x in the tool). Mechanism…

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

4 participants