Skip to content

Commit 9b42858

Browse files
committed
fix: inline local $ref in tool inputSchema for LLM consumption
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, #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: #1562
1 parent fcde488 commit 9b42858

5 files changed

Lines changed: 450 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/core': patch
3+
---
4+
5+
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.

packages/core/src/util/schema.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,104 @@ export type AnyObjectSchema = z.core.$ZodObject;
2020
*/
2121
export type SchemaOutput<T extends AnySchema> = z.output<T>;
2222

23+
/**
24+
* Resolves all local `$ref` pointers in a JSON Schema by inlining the
25+
* referenced definitions. Removes `$defs`/`definitions` from the output.
26+
*
27+
* - Caches resolved defs to avoid redundant work with diamond references
28+
* (A→B→D, A→C→D — D is resolved once and reused).
29+
* - Throws on cycles — recursive schemas cannot be represented without `$ref`
30+
* and LLMs cannot handle them. Fail loud so the developer knows to
31+
* restructure their schema.
32+
* - Preserves sibling keywords alongside `$ref` per JSON Schema 2020-12
33+
* (e.g. `{ "$ref": "...", "description": "override" }`).
34+
*
35+
* @internal Exported for testing only.
36+
*/
37+
export function dereferenceLocalRefs(schema: Record<string, unknown>): Record<string, unknown> {
38+
const defs: Record<string, unknown> =
39+
(schema['$defs'] as Record<string, unknown>) ?? (schema['definitions'] as Record<string, unknown>) ?? {};
40+
41+
// Cache resolved defs to avoid redundant traversal on diamond references.
42+
// Note: cached values are shared by reference. This is safe because schemas
43+
// are treated as immutable after generation. If a consumer mutates a schema,
44+
// they'd need to deep-clone it first regardless.
45+
const cache = new Map<string, unknown>();
46+
47+
function resolve(node: unknown, stack: Set<string>): unknown {
48+
if (node === null || typeof node !== 'object') return node;
49+
if (Array.isArray(node)) return node.map(item => resolve(item, stack));
50+
51+
const obj = node as Record<string, unknown>;
52+
53+
if (typeof obj['$ref'] === 'string') {
54+
const ref = obj['$ref'] as string;
55+
56+
// Collect sibling keywords (JSON Schema 2020-12 allows keywords alongside $ref)
57+
const { $ref: _ref, ...siblings } = obj;
58+
void _ref;
59+
const hasSiblings = Object.keys(siblings).length > 0;
60+
61+
let resolved: unknown;
62+
63+
if (ref === '#') {
64+
// Self-referencing root
65+
if (stack.has(ref)) {
66+
throw new Error(
67+
'Recursive schema detected: the root schema references itself. ' +
68+
'MCP tool schemas cannot contain cycles because LLMs cannot resolve $ref pointers.'
69+
);
70+
}
71+
const { $defs: _defs, definitions: _definitions, ...rest } = schema;
72+
void _defs;
73+
void _definitions;
74+
stack.add(ref);
75+
resolved = resolve(rest, stack);
76+
stack.delete(ref);
77+
} else {
78+
// Local definition: #/$defs/Name or #/definitions/Name
79+
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
80+
if (!match) return obj; // Non-local $ref — leave as-is
81+
82+
const defName = match[1]!;
83+
const def = defs[defName];
84+
if (def === undefined) return obj; // Unknown def — leave as-is
85+
86+
if (stack.has(defName)) {
87+
throw new Error(
88+
`Recursive schema detected: cycle through definition "${defName}". ` +
89+
'MCP tool schemas cannot contain cycles because LLMs cannot resolve $ref pointers.'
90+
);
91+
}
92+
93+
if (cache.has(defName)) {
94+
resolved = cache.get(defName);
95+
} else {
96+
stack.add(defName);
97+
resolved = resolve(def, stack);
98+
stack.delete(defName);
99+
cache.set(defName, resolved);
100+
}
101+
}
102+
103+
// Merge sibling keywords onto the resolved schema
104+
if (hasSiblings && resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
105+
return { ...(resolved as Record<string, unknown>), ...siblings };
106+
}
107+
return resolved;
108+
}
109+
110+
const result: Record<string, unknown> = {};
111+
for (const [key, value] of Object.entries(obj)) {
112+
if (key === '$defs' || key === 'definitions') continue;
113+
result[key] = resolve(value, stack);
114+
}
115+
return result;
116+
}
117+
118+
return resolve(schema, new Set()) as Record<string, unknown>;
119+
}
120+
23121
/**
24122
* Parses data against a Zod schema (synchronous).
25123
* Returns a discriminated union with success/error.

packages/core/src/util/standardSchema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

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

9+
import { dereferenceLocalRefs } from './schema.js';
10+
911
// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)
1012

1113
export interface StandardTypedV1<Input = unknown, Output = Input> {
@@ -156,7 +158,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
156158
`Wrap your schema in z.object({...}) or equivalent.`
157159
);
158160
}
159-
return { type: 'object', ...result };
161+
return dereferenceLocalRefs({ type: 'object', ...result });
160162
}
161163

162164
// Validation

packages/core/test/schema.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Unit tests for dereferenceLocalRefs
2+
// Tests raw JSON Schema edge cases independent of the server/client pipeline.
3+
// See: https://github.com/anthropics/claude-code/issues/18260
4+
5+
import { describe, expect, test } from 'vitest';
6+
7+
import { dereferenceLocalRefs } from '../src/util/schema.js';
8+
9+
describe('dereferenceLocalRefs', () => {
10+
test('schema with no $ref passes through unchanged', () => {
11+
const schema = {
12+
type: 'object',
13+
properties: { name: { type: 'string' }, age: { type: 'number' } }
14+
};
15+
const result = dereferenceLocalRefs(schema);
16+
expect(result).toEqual(schema);
17+
});
18+
19+
test('local $ref is inlined and $defs removed', () => {
20+
const schema = {
21+
type: 'object',
22+
properties: {
23+
primary: { $ref: '#/$defs/Tag' },
24+
secondary: { $ref: '#/$defs/Tag' }
25+
},
26+
$defs: {
27+
Tag: { type: 'object', properties: { label: { type: 'string' } } }
28+
}
29+
};
30+
const result = dereferenceLocalRefs(schema);
31+
expect(JSON.stringify(result)).not.toContain('$ref');
32+
expect(JSON.stringify(result)).not.toContain('$defs');
33+
expect(result['properties']).toMatchObject({
34+
primary: { type: 'object', properties: { label: { type: 'string' } } },
35+
secondary: { type: 'object', properties: { label: { type: 'string' } } }
36+
});
37+
});
38+
39+
test('diamond references resolve correctly', () => {
40+
const schema = {
41+
type: 'object',
42+
properties: {
43+
b: { type: 'object', properties: { inner: { $ref: '#/$defs/Shared' } } },
44+
c: { type: 'object', properties: { inner: { $ref: '#/$defs/Shared' } } }
45+
},
46+
$defs: {
47+
Shared: { type: 'object', properties: { x: { type: 'number' } } }
48+
}
49+
};
50+
const result = dereferenceLocalRefs(schema);
51+
expect(JSON.stringify(result)).not.toContain('$ref');
52+
const props = result['properties'] as Record<string, Record<string, unknown>>;
53+
const bInner = (props['b']!['properties'] as Record<string, unknown>)['inner'];
54+
const cInner = (props['c']!['properties'] as Record<string, unknown>)['inner'];
55+
expect(bInner).toMatchObject({ type: 'object', properties: { x: { type: 'number' } } });
56+
expect(cInner).toMatchObject({ type: 'object', properties: { x: { type: 'number' } } });
57+
});
58+
59+
test('non-existent $def reference is left as-is', () => {
60+
const schema = {
61+
type: 'object',
62+
properties: {
63+
broken: { $ref: '#/$defs/DoesNotExist' }
64+
},
65+
$defs: {}
66+
};
67+
const result = dereferenceLocalRefs(schema);
68+
expect((result['properties'] as Record<string, unknown>)['broken']).toEqual({ $ref: '#/$defs/DoesNotExist' });
69+
});
70+
71+
test('external $ref is left as-is', () => {
72+
const schema = {
73+
type: 'object',
74+
properties: {
75+
ext: { $ref: 'https://example.com/schemas/Foo.json' }
76+
}
77+
};
78+
const result = dereferenceLocalRefs(schema);
79+
expect((result['properties'] as Record<string, unknown>)['ext']).toEqual({
80+
$ref: 'https://example.com/schemas/Foo.json'
81+
});
82+
});
83+
84+
test('sibling keywords alongside $ref are preserved', () => {
85+
const schema = {
86+
type: 'object',
87+
properties: {
88+
addr: { $ref: '#/$defs/Address', description: 'Home address' }
89+
},
90+
$defs: {
91+
Address: { type: 'object', properties: { street: { type: 'string' } } }
92+
}
93+
};
94+
const result = dereferenceLocalRefs(schema);
95+
const addr = (result['properties'] as Record<string, unknown>)['addr'] as Record<string, unknown>;
96+
expect(addr['type']).toBe('object');
97+
expect(addr['properties']).toEqual({ street: { type: 'string' } });
98+
expect(addr['description']).toBe('Home address');
99+
});
100+
101+
test('recursive $ref through $defs throws', () => {
102+
const schema = {
103+
type: 'object',
104+
properties: {
105+
value: { type: 'string' },
106+
children: { type: 'array', items: { $ref: '#/$defs/TreeNode' } }
107+
},
108+
$defs: {
109+
TreeNode: {
110+
type: 'object',
111+
properties: {
112+
value: { type: 'string' },
113+
children: { type: 'array', items: { $ref: '#/$defs/TreeNode' } }
114+
}
115+
}
116+
}
117+
};
118+
expect(() => dereferenceLocalRefs(schema)).toThrow(/Recursive schema detected/);
119+
});
120+
121+
test('$ref: "#" root self-reference throws', () => {
122+
const schema = {
123+
type: 'object',
124+
properties: {
125+
name: { type: 'string' },
126+
child: { $ref: '#' }
127+
}
128+
};
129+
expect(() => dereferenceLocalRefs(schema)).toThrow(/Recursive schema detected/);
130+
});
131+
});

0 commit comments

Comments
 (0)