Skip to content
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 are handled gracefully — cyclic `$ref` pointers are left in place with only their `$defs` entries preserved, while all non-cyclic refs are fully inlined.
116 changes: 116 additions & 0 deletions packages/core/src/util/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,122 @@ export type AnyObjectSchema = z.core.$ZodObject;
*/
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.
// 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;
result[key] = inlineRefs(value, stack);
}
return result;
Comment thread
claude[bot] marked this conversation as 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.
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