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` before returning from `schemaToJson()`. LLMs cannot resolve JSON Schema `$ref` and serialize referenced parameters as strings instead of objects. This ensures tool schemas are self-contained and LLM-consumable.
96 changes: 95 additions & 1 deletion packages/core/src/util/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,103 @@ export type SchemaOutput<T extends AnySchema> = z.output<T>;

/**
* Converts a Zod schema to JSON Schema.
* Inlines all local $ref pointers so the output is self-contained.
*
* LLMs consuming tool inputSchema cannot resolve $ref — they serialize
* referenced parameters as strings instead of objects. While $ref was always
* possible, PR #1460's switch to z.toJSONSchema() widened the blast radius
* (globalRegistry, z.lazy). See ADR-0001.
*/
export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record<string, unknown> {
return z.toJSONSchema(schema, options) as Record<string, unknown>;
const jsonSchema = z.toJSONSchema(schema, options) as Record<string, unknown>;
return dereferenceLocalRefs(jsonSchema);
}

/**
* Resolves all local `$ref` pointers in a JSON Schema by inlining the
* referenced definitions. Removes `$defs`/`definitions` from the output.
*
* - Caches resolved defs to avoid redundant work with diamond references
* (A→B→D, A→C→D — D is resolved once and reused).
* - Detects cycles via a resolution stack and emits `{ type: "object" }`
* as a bounded fallback for recursive positions.
* - Preserves sibling keywords alongside `$ref` per JSON Schema 2020-12
* (e.g. `{ "$ref": "...", "description": "override" }`).
*
* @internal Not part of the public API — only used by {@link schemaToJson}.
* Exported for testing only.
*/
export function dereferenceLocalRefs(schema: Record<string, unknown>): Record<string, unknown> {
const defs: Record<string, unknown> =
(schema['$defs'] as Record<string, unknown>) ?? (schema['definitions'] as Record<string, unknown>) ?? {};

// Cache resolved defs to avoid redundant traversal on diamond references.
// Note: cached values are shared by reference. This is safe because schemas
// are treated as immutable after generation. If a consumer mutates a schema,
// they'd need to deep-clone it first regardless.
const cache = new Map<string, unknown>();

function resolve(node: unknown, stack: Set<string>): unknown {
if (node === null || typeof node !== 'object') return node;
if (Array.isArray(node)) return node.map(item => resolve(item, stack));

const obj = node as Record<string, unknown>;

if (typeof obj['$ref'] === 'string') {
const ref = obj['$ref'] as string;

// Collect sibling keywords (JSON Schema 2020-12 allows keywords alongside $ref)
const { $ref: _ref, ...siblings } = obj;
void _ref;
const hasSiblings = Object.keys(siblings).length > 0;

let resolved: unknown;

if (ref === '#') {
// Self-referencing root
if (stack.has(ref)) return { type: 'object' };
const { $defs: _defs, definitions: _definitions, ...rest } = schema;
void _defs;
void _definitions;
stack.add(ref);
resolved = resolve(rest, stack);
stack.delete(ref);
} else {
// Local definition: #/$defs/Name or #/definitions/Name
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
if (!match) return obj; // Non-local $ref — leave as-is

const defName = match[1]!;
const def = defs[defName];
if (def === undefined) return obj; // Unknown def — leave as-is
if (stack.has(defName)) return { type: 'object' };

if (cache.has(defName)) {
resolved = cache.get(defName);
} else {
stack.add(defName);
resolved = resolve(def, stack);
stack.delete(defName);
cache.set(defName, resolved);
}
}

// Merge sibling keywords onto the resolved schema
if (hasSiblings && resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
return { ...(resolved as Record<string, unknown>), ...siblings };
}
return resolved;
}

const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key === '$defs' || key === 'definitions') continue;
result[key] = resolve(value, stack);
}
return result;
}

return resolve(schema, new Set()) as Record<string, unknown>;
}

/**
Expand Down
144 changes: 144 additions & 0 deletions packages/core/test/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Unit tests for dereferenceLocalRefs and schemaToJson
// Tests raw JSON Schema edge cases independent of the server/client pipeline.
// See: https://github.com/anthropics/claude-code/issues/18260

import { describe, expect, test, afterEach } from 'vitest';
import * as z from 'zod/v4';
import { dereferenceLocalRefs, schemaToJson } from '../src/util/schema.js';

describe('schemaToJson $ref dereferencing', () => {
const registeredSchemas: z.core.$ZodType[] = [];
afterEach(() => {
for (const s of registeredSchemas) z.globalRegistry.remove(s);
registeredSchemas.length = 0;
});

test('passthrough: schema with no $ref is unchanged', () => {
const result = schemaToJson(z.object({ name: z.string(), age: z.number() }), { io: 'input' });
expect(result).toMatchObject({
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'number' } }
});
expect(JSON.stringify(result)).not.toContain('$ref');
});

test('registered types are inlined and $defs removed', () => {
const Tag = z.object({ label: z.string() });
z.globalRegistry.add(Tag, { id: 'Tag' });
registeredSchemas.push(Tag);

const result = schemaToJson(z.object({ primary: Tag, secondary: Tag }), { io: 'input' });
expect(JSON.stringify(result)).not.toContain('$ref');
expect(JSON.stringify(result)).not.toContain('$defs');
expect(result['properties']).toMatchObject({
primary: { type: 'object', properties: { label: { type: 'string' } } },
secondary: { type: 'object', properties: { label: { type: 'string' } } }
});
});

test('recursive types produce { type: "object" } at cycle point', () => {
const TreeNode: z.ZodType = z.object({
value: z.string(),
children: z.lazy(() => z.array(TreeNode))
});
const result = schemaToJson(z.object({ root: TreeNode }), { io: 'input' });
expect(JSON.stringify(result)).not.toContain('$ref');
expect(JSON.stringify(result)).not.toContain('$defs');

const root = (result['properties'] as Record<string, unknown>)['root'] as Record<string, unknown>;
expect(root).toHaveProperty('type', 'object');
const children = (root['properties'] as Record<string, unknown>)['children'] as Record<string, unknown>;
expect(children).toHaveProperty('type', 'array');
expect(children['items']).toMatchObject({ type: 'object' });
});

test('diamond references resolve correctly', () => {
const Shared = z.object({ x: z.number() });
z.globalRegistry.add(Shared, { id: 'Shared' });
registeredSchemas.push(Shared);

const result = schemaToJson(
z.object({
b: z.object({ inner: Shared }),
c: z.object({ inner: Shared })
}),
{ io: 'input' }
);

expect(JSON.stringify(result)).not.toContain('$ref');
const props = result['properties'] as Record<string, Record<string, unknown>>;
const bInner = (props['b']!['properties'] as Record<string, unknown>)['inner'];
const cInner = (props['c']!['properties'] as Record<string, unknown>)['inner'];
expect(bInner).toMatchObject({ type: 'object', properties: { x: { type: 'number' } } });
expect(cInner).toMatchObject({ type: 'object', properties: { x: { type: 'number' } } });
});
});

describe('dereferenceLocalRefs edge cases', () => {
test('non-existent $def reference is left as-is', () => {
const schema = {
type: 'object',
properties: {
broken: { $ref: '#/$defs/DoesNotExist' }
},
$defs: {}
};
const result = dereferenceLocalRefs(schema);
expect((result['properties'] as Record<string, unknown>)['broken']).toEqual({ $ref: '#/$defs/DoesNotExist' });
});

test('external $ref is left as-is', () => {
const schema = {
type: 'object',
properties: {
ext: { $ref: 'https://example.com/schemas/Foo.json' }
}
};
const result = dereferenceLocalRefs(schema);
expect((result['properties'] as Record<string, unknown>)['ext']).toEqual({ $ref: 'https://example.com/schemas/Foo.json' });
});

test('sibling keywords alongside $ref are preserved', () => {
const schema = {
type: 'object',
properties: {
addr: { $ref: '#/$defs/Address', description: 'Home address' }
},
$defs: {
Address: { type: 'object', properties: { street: { type: 'string' } } }
}
};
const result = dereferenceLocalRefs(schema);
const addr = (result['properties'] as Record<string, unknown>)['addr'] as Record<string, unknown>;
expect(addr['type']).toBe('object');
expect(addr['properties']).toEqual({ street: { type: 'string' } });
expect(addr['description']).toBe('Home address');
});

test('$ref: "#" root self-reference with cycle detection', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
child: { $ref: '#' }
}
};
const result = dereferenceLocalRefs(schema);
expect(JSON.stringify(result)).not.toContain('$ref');
const child = (result['properties'] as Record<string, unknown>)['child'] as Record<string, unknown>;
expect(child['type']).toBe('object');
expect((child['properties'] as Record<string, unknown>)['name']).toEqual({ type: 'string' });
// Recursive position should be bounded
const grandchild = (child['properties'] as Record<string, unknown>)['child'] as Record<string, unknown>;
expect(grandchild).toEqual({ type: 'object' });
});

test('schema with no $ref passes through unchanged', () => {
const schema = {
type: 'object',
properties: { x: { type: 'number' } }
};
const result = dereferenceLocalRefs(schema);
expect(result).toEqual(schema);
});
});
Loading
Loading