Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/inline-ref-in-tool-schema.md
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

View check run for this annotation

Claude / Claude Code Review

Changeset description incorrect: claims recursive schemas throw, implementation does graceful degradation

The changeset description says "Recursive schemas now throw at `tools/list` time instead of silently degrading" but the actual implementation does the opposite — cyclic $refs are left in place with their `$defs` entries preserved. Update the changeset to reflect the graceful degradation behavior that was implemented at reviewer request.
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 changeset description says "Recursive schemas now throw at tools/list time instead of silently degrading" but the actual implementation does the opposite — cyclic $refs are left in place with their $defs entries 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 at tools/list time 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, the inlineRefs function handles cycles at line ~95:

if (stack.has(defName)) {
    cyclicDefs.add(defName);
    return obj; // Cycle — leave $ref in place
}

No exception is thrown. After traversal, cyclic $defs entries are re-attached to the output so the remaining $ref pointers 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 gogakoreli on 2026-03-31 describes exactly that behavior: "Fails at tools/list time so the developer knows immediately to restructure". But felixweinberger requested graceful degradation instead (best-effort dereferencing, leaving cyclic $refs in place with only those $defs entries preserved), and gogakoreli implemented 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 at tools/list time — 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

  1. Read .changeset/inline-ref-in-tool-schema.md: "Recursive schemas now throw at tools/list time instead of silently degrading."
  2. Read packages/core/src/util/schema.ts cycle detection path: cyclicDefs.add(defName); return obj; // Cycle — leave $ref in place — no throw.
  3. Read the integration test assertion: "Recursive schemas should NOT throw" with expect({ tools }).resolves (no throw expected).
  4. Cross-reference PR timeline: felixweinberger (2026-04-02) explicitly requested graceful degradation; gogakoreli agreed 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 $ref pointers are left in place with only their $defs entries preserved, while all non-cyclic refs are fully inlined. No exception is thrown for recursive schemas."

113 changes: 113 additions & 0 deletions packages/core/src/util/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

View check run for this annotation

Claude / Claude Code Review

Boolean JSON Schema defs cause silent sibling keyword loss when merged with $ref

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.
Comment on lines +104 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 inlineRefs (schema.ts line 104-109), when a $ref resolves to a definition and the $ref node has sibling keywords, those siblings are merged only when the resolved value passes the guard resolved \!== null && typeof resolved === "object" && \!Array.isArray(resolved). JSON Schema allows boolean values (true/false) as valid schemas inside $defs. If a def resolves to a boolean, typeof resolved === "object" returns false, and any sibling keywords (description, title, default, etc.) are silently dropped — the function returns the bare boolean.

The specific code path that triggers it

Given a schema: { type: "object", properties: { x: { $ref: "#/$defs/AlwaysValid", description: "Any value" } }, $defs: { AlwaysValid: true } }. When inlineRefs processes properties.x, it sees a $ref, resolves it to true, then hits the sibling-merge guard: typeof true \!== "object" → guard fails → returns true directly, discarding description: "Any value".

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 $ref per JSON Schema 2020-12" — but the implementation only does so for object-valued defs. There is no warning or error; the drop is silent.

Impact and practical severity

This is a nit. None of the schema libraries this PR targets (Zod v4, ArkType, Valibot) produce boolean schemas in $defs. Zod's z.any()/z.unknown() produce {} and z.never() produces {not:{}}. The function is @internal and only invoked from standardSchemaToJsonSchema(), so the trigger requires hand-crafted JSON Schema with boolean defs and sibling keywords on the pointing $ref — a combination no real generator produces for MCP tool schemas.

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 allOf alongside the siblings, which is the canonical JSON Schema 2020-12 idiom. Alternatively, just allow the sibling merge to proceed even for boolean schemas by restructuring the returned value. If boolean schemas truly cannot occur in practice, a code comment documenting the known limitation is the minimal fix to align the implementation with the stated contract.

}

// 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;
}

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.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

/* eslint-disable @typescript-eslint/no-namespace */

import { dereferenceLocalRefs } from './schema.js';

// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)

export interface StandardTypedV1<Input = unknown, Output = Input> {
Expand Down Expand Up @@ -156,7 +158,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
`Wrap your schema in z.object({...}) or equivalent.`
);
}
return { type: 'object', ...result };
return dereferenceLocalRefs({ type: 'object', ...result });
}

// Validation
Expand Down
Loading
Loading