-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix: inline local $ref in tool inputSchema for LLM consumption #1563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dc5055f
469805c
ccf0396
b20f749
7d22a03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/core': patch | ||
| --- | ||
|
|
||
| Inline local `$ref` pointers in tool `inputSchema` so schemas are self-contained and LLM-consumable. LLMs cannot resolve JSON Schema `$ref` — they serialize referenced parameters as strings instead of objects. Recursive schemas now throw at `tools/list` time instead of silently degrading. | ||
|
Check warning on line 5 in .changeset/inline-ref-in-tool-schema.md
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,119 @@ | |
| */ | ||
| export type SchemaOutput<T extends AnySchema> = z.output<T>; | ||
|
|
||
| /** | ||
| * Resolves all local `$ref` pointers in a JSON Schema by inlining the | ||
| * referenced definitions. | ||
| * | ||
| * - Caches resolved defs to avoid redundant work with diamond references | ||
| * (A→B→D, A→C→D — D is resolved once and reused). | ||
| * - Gracefully handles cycles — cyclic `$ref` are left in place with their | ||
| * `$defs` entries preserved. Non-cyclic refs in the same schema are still | ||
| * fully inlined. This avoids breaking existing servers that have recursive | ||
| * schemas which work (degraded) today. | ||
| * - Preserves sibling keywords alongside `$ref` per JSON Schema 2020-12 | ||
| * (e.g. `{ "$ref": "...", "description": "override" }`). | ||
| * | ||
| * @internal Exported for testing only. | ||
| */ | ||
| export function dereferenceLocalRefs(schema: Record<string, unknown>): Record<string, unknown> { | ||
| // "$defs" is the standard keyword since JSON Schema 2019-09. | ||
| // See: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.4 | ||
| // "definitions" is the legacy equivalent from drafts 04–07. | ||
| // See: https://json-schema.org/draft-07/json-schema-validation#section-9 | ||
| // If both exist (malformed schema), "$defs" takes precedence. | ||
| const defsKey = '$defs' in schema ? '$defs' : 'definitions' in schema ? 'definitions' : undefined; | ||
| const defs: Record<string, unknown> = defsKey ? (schema[defsKey] as Record<string, unknown>) : {}; | ||
|
|
||
| // No definitions container — nothing to inline. | ||
| // Note: $ref: "#" (root self-reference) is intentionally not handled — no schema | ||
| // library produces it, no other MCP SDK handles it, and it's always cyclic. | ||
| if (!defsKey) return schema; | ||
|
|
||
| // Cache resolved defs to avoid redundant traversal on diamond references | ||
| // (A→B→D, A→C→D — D is resolved once and reused). Cached values are shared | ||
| // by reference, which is safe because schemas are immutable after generation. | ||
| const resolvedDefs = new Map<string, unknown>(); | ||
| // Def names where a cycle was detected — these $ref are left in place | ||
| // and their $defs entries must be preserved in the output. | ||
| const cyclicDefs = new Set<string>(); | ||
|
|
||
| /** | ||
| * Recursively inlines `$ref` pointers in a JSON Schema node by replacing | ||
| * them with the referenced definition content. | ||
| * | ||
| * @param node - The current schema node being traversed. | ||
| * @param stack - Def names currently being inlined (ancestor chain). If a | ||
| * def is encountered while already on the stack, it's a cycle — the | ||
| * `$ref` is left in place and the def name is added to `cyclicDefs`. | ||
| */ | ||
| function inlineRefs(node: unknown, stack: Set<string>): unknown { | ||
| if (node === null || typeof node !== 'object') return node; | ||
| if (Array.isArray(node)) return node.map(item => inlineRefs(item, stack)); | ||
|
|
||
| const obj = node as Record<string, unknown>; | ||
|
|
||
| // JSON Schema 2020-12 allows keywords alongside $ref (e.g. description, default). | ||
| // Destructure to get the ref target and any sibling keywords to merge later. | ||
| const { $ref: ref, ...siblings } = obj; | ||
| if (typeof ref === 'string') { | ||
| const hasSiblings = Object.keys(siblings).length > 0; | ||
|
|
||
| let resolved: unknown; | ||
|
|
||
| // Local definition reference: #/$defs/Name or #/definitions/Name | ||
| const prefix = `#/${defsKey}/`; | ||
| if (!ref.startsWith(prefix)) return obj; // Non-local $ref (external URL, etc.) — leave as-is | ||
|
|
||
| const defName = ref.slice(prefix.length); | ||
| const def = defs[defName]; | ||
| 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 | ||
| 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; | ||
|
Check warning on line 109 in packages/core/src/util/schema.ts
|
||
|
Comment on lines
+104
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 When a $ref resolves to a boolean JSON Schema (true or false), sibling keywords like description, title, and default are silently dropped because the merge condition at line 105 requires typeof resolved === 'object', which fails for booleans. This violates the JSDoc's stated contract of preserving sibling keywords per JSON Schema 2020-12, though no schema library currently targeted by this PR (Zod v4, ArkType, Valibot) produces boolean schemas in $defs. Extended reasoning...What the bug is and how it manifests In The specific code path that triggers it Given a schema: Why existing code doesn't prevent it The guard at line 104-105 was written for the object case and was never extended for boolean schemas. The JSDoc explicitly states "Preserves sibling keywords alongside Impact and practical severity This is a nit. None of the schema libraries this PR targets (Zod v4, ArkType, Valibot) produce boolean schemas in How to fix it Expand the merge condition (or add a separate else-if branch) to handle boolean schemas. One approach: wrap the boolean in an |
||
| } | ||
|
|
||
| // 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; | ||
| result[key] = inlineRefs(value, stack); | ||
| } | ||
| return result; | ||
claude[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| const resolved = inlineRefs(schema, new Set()) as Record<string, unknown>; | ||
|
|
||
| // Re-attach $defs only for cyclic definitions, using their resolved/cached | ||
| // versions so that any non-cyclic refs inside them are already inlined. | ||
| if (defsKey && cyclicDefs.size > 0) { | ||
| const prunedDefs: Record<string, unknown> = {}; | ||
| for (const name of cyclicDefs) { | ||
| prunedDefs[name] = resolvedDefs.get(name) ?? defs[name]; | ||
| } | ||
| resolved[defsKey] = prunedDefs; | ||
| } | ||
|
|
||
| return resolved; | ||
| } | ||
|
|
||
| /** | ||
| * Parses data against a Zod schema (synchronous). | ||
| * Returns a discriminated union with success/error. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The changeset description says "Recursive schemas now throw at
tools/listtime instead of silently degrading" but the actual implementation does the opposite — cyclic $refs are left in place with their$defsentries preserved. Update the changeset to reflect the graceful degradation behavior that was implemented at reviewer request.Extended reasoning...
What the bug is
The changeset file (
.changeset/inline-ref-in-tool-schema.md, line 5) states: "Recursive schemas now throw attools/listtime instead of silently degrading." This is factually wrong — the final implementation performs graceful degradation, not throwing.How the implementation actually behaves
In
packages/core/src/util/schema.ts, theinlineRefsfunction handles cycles at line ~95:No exception is thrown. After traversal, cyclic
$defsentries are re-attached to the output so the remaining$refpointers are still resolvable. The integration test confirms this explicitly with a comment: "Recursive schemas should NOT throw — cyclic $ref stays in place".How this discrepancy arose
The PR timeline shows that an earlier iteration of the PR did throw on recursive schemas. The comment from
gogakorelion 2026-03-31 describes exactly that behavior: "Fails attools/listtime so the developer knows immediately to restructure". Butfelixweinbergerrequested graceful degradation instead (best-effort dereferencing, leaving cyclic $refs in place with only those$defsentries preserved), andgogakoreliimplemented it on 2026-04-02. The changeset was never updated to match.Why existing safeguards don't prevent it
The changeset is a static markdown file — there is no tooling that validates its description against the actual behavior. The changeset bot only checks that a changeset exists; it cannot verify correctness of the prose.
Impact
Users reading the changelog to understand migration impact will believe that any server using
z.lazy()recursive schemas will start throwing attools/listtime — causing them to anticipate breaking changes that don't exist. Developers may unnecessarily restructure their schemas to avoid an error that will never be raised. The actual behavior (graceful degradation with cyclic refs preserved) is a non-breaking improvement, but the changeset describes a breaking change.Step-by-step proof
.changeset/inline-ref-in-tool-schema.md: "Recursive schemas now throw attools/listtime instead of silently degrading."packages/core/src/util/schema.tscycle detection path:cyclicDefs.add(defName); return obj; // Cycle — leave $ref in place— no throw.expect({ tools }).resolves(no throw expected).felixweinberger(2026-04-02) explicitly requested graceful degradation;gogakoreliagreed and updated the implementation but not the changeset.How to fix
Update the changeset to accurately describe the behavior: "Recursive schemas are handled gracefully — cyclic
$refpointers are left in place with only their$defsentries preserved, while all non-cyclic refs are fully inlined. No exception is thrown for recursive schemas."