fix: inline local $ref in tool inputSchema for LLM consumption - #1563
fix: inline local $ref in tool inputSchema for LLM consumption#1563gogakoreli wants to merge 8 commits into
Conversation
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
cc0d216 to
925ab05
Compare
🦋 Changeset detectedLatest commit: 290ebc6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
|
I also have a detailed ADR documenting the investigation, alternatives analysis, and design rationale. Happy to include it as |
There was a problem hiding this comment.
Thanks for working on this, appreciate the thorough work here.
-
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 intostandardSchemaToJsonSchema()inpackages/core/src/util/standardSchema.ts. -
Instead of leaving
$refI 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.
925ab05 to
e7895ce
Compare
|
Thanks for the review @felixweinberger — both items addressed. 1. Rebased and hooked into
2. Cycles throw instead of Fails at Cross-SDK note: Neither the Go nor Python SDK dereferences |
35edf9b to
9b42858
Compare
|
@claude review |
1 similar comment
|
@claude review |
|
@claude review |
…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.
|
@claude review |
1 similar comment
|
@claude review |
There was a problem hiding this comment.
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:
- Sibling keywords containing nested
$ref— siblings are now passed throughresolve()viaObject.fromEntries(Object.entries(siblings).map(([k, v]) => [k, resolve(v, stack)])). $defsleaking through sibling merge path — covered by fix #1 (resolved siblings cannot contain raw$defs).- Nested property named
definitions/$defssilently dropped — guarded withobj === schemaso 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.
There was a problem hiding this comment.
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:
- Inline every
$refthat isn't part of a cycle - For the ones that are, leave the
$refin place and keep only those$defsentries. - That means non-recursive schemas get fully inlined, while recursive ones behave like today (no regression).
- Potential reference approach here: https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L103
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 |
b8a39a9 to
c7b353e
Compare
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
|
@claude review |
7d22a03 to
7ef2c04
Compare
|
Both items addressed in the latest push: Changeset — Updated to accurately describe the graceful degradation behavior (cyclic Boolean |
|
cc @felixweinberger, ready for re-review |
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
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
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>
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.
9674406 to
4fd247b
Compare
|
@felixweinberger Rebased onto What moved:
One behavioral note surfaced by the rebase: the fix now also reaches elicitation schemas. A hand-built Clarification on cycle handling (the thread history is confusing on this): an earlier revision threw on cycles; the final implementation gracefully degrades instead — cyclic Verification on the rebased branch:
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.
|
Fixed the That scenario (SEP-1613) requires hand-authored schemas registered via
This also lands exactly on the issue's original scoping — the fix targets schemas where Added regression tests for both sides. Full local run: conformance server suite green (only pre-existing baselined |
|
@claude review |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
🟡 (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.…
| 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 }; |
There was a problem hiding this comment.
🟡 (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,…
| const defsKey = '$defs' in schema ? '$defs' : 'definitions' in schema ? 'definitions' : undefined; | ||
| const defs: Record<string, unknown> = defsKey ? (schema[defsKey] as Record<string, unknown>) : {}; |
There was a problem hiding this comment.
🟡 (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'…
| if (resolvedDefs.has(defName)) { | ||
| resolved = resolvedDefs.get(defName); | ||
| } else { | ||
| stack.add(defName); | ||
| resolved = inlineRefs(def, stack); | ||
| stack.delete(defName); | ||
| resolvedDefs.set(defName, resolved); |
There was a problem hiding this comment.
🟡 (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…
Inline local
$refin toolinputSchemafor LLM consumptionFixes: #1562
Related: anthropics/claude-code#18260
Problem
Tool
inputSchemacontaining$refpointers 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:$refin tool schemas has always been possible (non-Zod servers, and the oldzod-to-json-schemawith its default$refStrategy: "root"for identity-based deduplication). However, #1460's switch toz.toJSONSchema()significantly widened the blast radius — registered types (z.globalRegistry) now produce$refeven 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()toschemaToJson()— inlines all local$refpointers (#/$defs/...,#/definitions/...) so tool schemas are self-contained.~95 lines of implementation, zero external dependencies.
Behavior:
$ref→ inlined,$defs/definitionsstripped$ref(URLs) → left as-is{ type: "object" }$ref→ preserved per JSON Schema 2020-12dereferenceLocalRefsis@internal— not intended as public APIAlternatives considered
zod-to-json-schemadereference-json-schemanpm packagereused: "inline"option$refregardless (verified)overridecallback$defsaren't fully built at override time; would need two-pass generationz.globalRegistry"globalRegistryis a legitimate Zod featureLimitations
schemaToJson(). Non-Zod servers sending raw JSON Schema with$refare not affected — their schemas don't hit this code path.{ type: "object" }vs the full type).Test plan
pnpm lint:allpassespnpm test:allpasses (all existing tests + 14 new)pnpm build:allpasses9 unit tests (
packages/core/test/schema.test.ts) — testdereferenceLocalRefsdirectly with crafted JSON Schema:$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 cleanup5 integration tests (
test/integration/test/server/mcp.test.ts) — full server→client pipeline:discriminatedUnion+ registry, mixed$ref+ inline params (Notion repro),$reftooneOfunion, recursive types$ref) AND runtimecallToolround-tripAll integration tests clean up
z.globalRegistryviaafterEach.Files changed
packages/core/src/util/schema.tsdereferenceLocalRefs(), modifyschemaToJson()to call itpackages/core/test/schema.test.tstest/integration/test/server/mcp.test.tsdescribeblock.changeset/inline-ref-in-tool-schema.md