Skip to content
7 changes: 7 additions & 0 deletions .changeset/inline-ref-in-tool-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': 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-internal/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
10 changes: 9 additions & 1 deletion packages/core-internal/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import * as z from 'zod/v4';

import type { StringSchema } from '../types/types';
import { dereferenceLocalRefs } from './schema';

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

Expand Down Expand Up @@ -231,7 +232,14 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
`Wrap your schema in z.object({...}) or equivalent.`
);
}
return { type: 'object', ...result };
// Hand-authored JSON Schema (wrapped via fromJsonSchema, vendor 'mcp') is advertised
// verbatim — SEP-1613 requires $schema/$defs/$ref to survive tools/list unchanged.
// Library-converted schemas (Zod, ArkType, Valibot) get local $ref inlined: their
// $ref is a conversion artifact (z.globalRegistry, z.lazy) that LLMs cannot resolve.
if (std.vendor === 'mcp') {
return { type: 'object', ...result };
}
return dereferenceLocalRefs({ type: 'object', ...result });
}

/**
Expand Down
45 changes: 25 additions & 20 deletions packages/core-internal/test/shared/inputRequired.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,27 +270,32 @@ describe('inputRequired() builder', () => {
expect(act).toThrow(TypeError);
expect(act).toThrow(/unsupported JSON Schema constraint\(s\).*additionalProperties/);

expect(() =>
inputRequired.elicit({
message: 'Name?',
requestedSchema: {
'~standard': {
version: 1,
vendor: 'test',
validate: (value: unknown) => ({ value }),
jsonSchema: {
input: () => ({
$defs: { Name: { type: 'string' } },
type: 'object',
properties: { name: { $ref: '#/$defs/Name' } },
required: ['name']
}),
output: () => ({})
}
// $defs/$ref no longer reach elicitation validation: standardSchemaToJsonSchema
// inlines local $ref and strips $defs, so the schema converts cleanly.
const request = inputRequired.elicit({
message: 'Name?',
requestedSchema: {
'~standard': {
version: 1,
vendor: 'test',
validate: (value: unknown) => ({ value }),
jsonSchema: {
input: () => ({
$defs: { Name: { type: 'string' } },
type: 'object',
properties: { name: { $ref: '#/$defs/Name' } },
required: ['name']
}),
output: () => ({})
}
} as never
})
).toThrow(/\$defs/);
}
} as never
});
expect((request.params as { requestedSchema: unknown }).requestedSchema).toEqual({
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
});
});

test('elicit drops annotation-only root keywords', () => {
Expand Down
Loading
Loading