From 925ab054e737a65b3a1c4e074882f1880f206532 Mon Sep 17 00:00:00 2001 From: Goga Koreli Date: Fri, 20 Feb 2026 13:04:02 -0800 Subject: [PATCH] fix: inline local $ref in tool inputSchema for LLM consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() to schemaToJson() which inlines all local $ref pointers, ensuring tool schemas are self-contained and LLM-consumable. Fixes: #1562 --- .changeset/inline-ref-in-tool-schema.md | 5 + packages/core/src/util/schema.ts | 96 +++++++++- packages/core/test/schema.test.ts | 144 +++++++++++++++ test/integration/test/server/mcp.test.ts | 222 +++++++++++++++++++++++ 4 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 .changeset/inline-ref-in-tool-schema.md create mode 100644 packages/core/test/schema.test.ts diff --git a/.changeset/inline-ref-in-tool-schema.md b/.changeset/inline-ref-in-tool-schema.md new file mode 100644 index 000000000..c3e8c2428 --- /dev/null +++ b/.changeset/inline-ref-in-tool-schema.md @@ -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. diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index adecee361..b571b3119 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -24,9 +24,103 @@ export type SchemaOutput = z.output; /** * 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 { - return z.toJSONSchema(schema, options) as Record; + const jsonSchema = z.toJSONSchema(schema, options) as Record; + 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): Record { + const defs: Record = + (schema['$defs'] as Record) ?? (schema['definitions'] as Record) ?? {}; + + // 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(); + + function resolve(node: unknown, stack: Set): 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; + + 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), ...siblings }; + } + return resolved; + } + + const result: Record = {}; + 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; } /** diff --git a/packages/core/test/schema.test.ts b/packages/core/test/schema.test.ts new file mode 100644 index 000000000..4d07e100e --- /dev/null +++ b/packages/core/test/schema.test.ts @@ -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)['root'] as Record; + expect(root).toHaveProperty('type', 'object'); + const children = (root['properties'] as Record)['children'] as Record; + 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>; + const bInner = (props['b']!['properties'] as Record)['inner']; + const cInner = (props['c']!['properties'] as Record)['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)['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)['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)['addr'] as Record; + 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)['child'] as Record; + expect(child['type']).toBe('object'); + expect((child['properties'] as Record)['name']).toEqual({ type: 'string' }); + // Recursive position should be bounded + const grandchild = (child['properties'] as Record)['child'] as Record; + 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); + }); +}); diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 091e4ac21..abe3b5289 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -5098,6 +5098,228 @@ describe('Zod v4', () => { }); }); + // https://github.com/anthropics/claude-code/issues/18260 + // Tool inputSchema must not contain $ref — LLMs cannot resolve JSON Schema + // references and will stringify object parameters instead of passing objects. + describe('Tool inputSchema should not contain $ref', () => { + // Track schemas registered in globalRegistry so we can clean up + const registeredSchemas: z.core.$ZodType[] = []; + afterEach(() => { + for (const schema of registeredSchemas) { + z.globalRegistry.remove(schema); + } + registeredSchemas.length = 0; + }); + + function registerInGlobal(schema: T, meta: { id: string }): T { + z.globalRegistry.add(schema, meta); + registeredSchemas.push(schema); + return schema; + } + + test('registered types should be inlined in schema and callable', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + const Address = registerInGlobal(z.object({ street: z.string(), city: z.string() }), { id: 'Address' }); + + server.registerTool('update-address', { inputSchema: z.object({ home: Address, work: Address }) }, async args => ({ + content: [{ type: 'text' as const, text: `home: ${args.home.city}, work: ${args.work.city}` }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Schema invariant: no $ref in the schema sent to clients + const { tools } = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + const schema = tools[0]!.inputSchema; + expect(JSON.stringify(schema)).not.toContain('$ref'); + expect(JSON.stringify(schema)).not.toContain('$defs'); + expect(schema.properties!['home']).toMatchObject({ + type: 'object', + properties: { street: { type: 'string' }, city: { type: 'string' } } + }); + + // Runtime invariant: callTool with object args should succeed + const result = await client.callTool({ + name: 'update-address', + arguments: { + home: { street: '123 Main St', city: 'Springfield' }, + work: { street: '456 Oak Ave', city: 'Shelbyville' } + } + }); + expect(result.content).toEqual([{ type: 'text', text: 'home: Springfield, work: Shelbyville' }]); + }); + + test('discriminatedUnion with registered types should be inlined and callable', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + const CreateOp = z.object({ type: z.literal('create'), file_text: z.string() }); + const AppendOp = z.object({ type: z.literal('append'), new_str: z.string() }); + registerInGlobal(CreateOp, { id: 'CreateOp' }); + registerInGlobal(AppendOp, { id: 'AppendOp' }); + + server.registerTool( + 'write-file', + { + inputSchema: z.object({ + path: z.string(), + operation: z.discriminatedUnion('type', [CreateOp, AppendOp]) + }) + }, + async args => ({ + content: [{ type: 'text' as const, text: `${args.operation.type}: ${args.path}` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Schema invariant: oneOf variants should be inline objects, not $ref + const { tools } = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + const schema = tools[0]!.inputSchema; + expect(JSON.stringify(schema)).not.toContain('$ref'); + expect(JSON.stringify(schema)).not.toContain('$defs'); + const operation = schema.properties!['operation'] as Record; + const variants = (operation['oneOf'] ?? operation['anyOf']) as Array>; + expect(variants).toBeDefined(); + expect(variants.length).toBe(2); + expect(variants[0]).toHaveProperty('type', 'object'); + expect(variants[1]).toHaveProperty('type', 'object'); + + // Runtime invariant: callTool with object operation should succeed + // This is exactly the case that fails when LLMs stringify $ref params + const result = await client.callTool({ + name: 'write-file', + arguments: { + path: '/tmp/test.md', + operation: { type: 'create', file_text: 'hello world' } + } + }); + expect(result.content).toEqual([{ type: 'text', text: 'create: /tmp/test.md' }]); + }); + + test('mixed $ref and inline params in same tool should both work', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + // Reproduces the original Notion MCP bug: one param uses $ref, + // another uses inline type: object — only the $ref param gets stringified + const ParentRequest = z.object({ database_id: z.string() }); + registerInGlobal(ParentRequest, { id: 'ParentRequest' }); + + server.registerTool( + 'create-page', + { + inputSchema: z.object({ + parent: ParentRequest, + properties: z.object({ + title: z.string() + }) + }) + }, + async args => ({ + content: [{ type: 'text' as const, text: `db: ${args.parent.database_id}, title: ${args.properties.title}` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const { tools } = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + const schema = tools[0]!.inputSchema; + + // Both params should be inline objects + expect(JSON.stringify(schema)).not.toContain('$ref'); + expect(schema.properties!['parent']).toMatchObject({ type: 'object' }); + expect(schema.properties!['properties']).toMatchObject({ type: 'object' }); + + const result = await client.callTool({ + name: 'create-page', + arguments: { + parent: { database_id: '2275ad9e-1234' }, + properties: { title: 'Test Page' } + } + }); + expect(result.content).toEqual([{ type: 'text', text: 'db: 2275ad9e-1234, title: Test Page' }]); + }); + + test('$ref pointing to oneOf union should be inlined', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + // Notion-style: $defs contains a oneOf union referenced via $ref + const ParentRequest = z.union([z.object({ database_id: z.string() }), z.object({ page_id: z.string() })]); + registerInGlobal(ParentRequest, { id: 'ParentRequest' }); + + server.registerTool( + 'create-item', + { + inputSchema: z.object({ parent: ParentRequest }) + }, + async _args => ({ + content: [{ type: 'text' as const, text: 'created' }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const { tools } = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + const schema = tools[0]!.inputSchema; + expect(JSON.stringify(schema)).not.toContain('$ref'); + expect(JSON.stringify(schema)).not.toContain('$defs'); + + // The inlined parent should have the union variants directly + const parent = schema.properties!['parent'] as Record; + const variants = (parent['oneOf'] ?? parent['anyOf']) as Array>; + expect(variants).toBeDefined(); + expect(variants.length).toBe(2); + + const result = await client.callTool({ + name: 'create-item', + arguments: { parent: { database_id: 'abc-123' } } + }); + expect(result.content).toEqual([{ type: 'text', text: 'created' }]); + }); + + test('recursive types should be inlined in schema', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + const TreeNode: z.ZodType = z.object({ + value: z.string(), + children: z.lazy(() => z.array(TreeNode)) + }); + + server.registerTool('process-tree', { inputSchema: z.object({ root: TreeNode }) }, async () => ({ + content: [{ type: 'text' as const, text: 'processed' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Schema invariant: no $ref in recursive schemas either + const { tools } = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + expect(JSON.stringify(tools[0]!.inputSchema)).not.toContain('$ref'); + + // Runtime invariant: nested object args should work + const result = await client.callTool({ + name: 'process-tree', + arguments: { + root: { value: 'root', children: [{ value: 'child', children: [] }] } + } + }); + expect(result.content).toEqual([{ type: 'text', text: 'processed' }]); + }); + }); + describe('Tools with transformation schemas', () => { test('should support z.preprocess() schemas', async () => { const server = new McpServer({