diff --git a/.changeset/inline-reused-zod-schemas.md b/.changeset/inline-reused-zod-schemas.md new file mode 100644 index 0000000000..464da15414 --- /dev/null +++ b/.changeset/inline-reused-zod-schemas.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Pass `reused: 'inline'` when converting Zod schemas to JSON Schema in `tools/list` and prompt argument schemas, so reused subschema instances are inlined rather than emitted as `$ref` pointers. Restores compatibility with strict MCP clients (e.g. kimi) that reject ref forms other than `#/$defs/...`. Fixes #2100. diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b938885de0..4498e9e1c4 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -178,7 +178,12 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in const std = schema['~standard']; let result: Record; if (std.jsonSchema) { - result = std.jsonSchema[io]({ target: 'draft-2020-12' }); + // Force `reused: 'inline'` so reused subschemas are inlined rather than emitted as + // `$ref` pointers. Strict MCP clients (e.g. kimi) reject ref forms other than + // `#/$defs/...`, and zod's `reused` default has varied across 4.x minors (the SDK + // pins `zod ^4.2.0`). Threading via `libraryOptions` is how zod's standard-schema + // entrypoint accepts converter options. See issue #2100. + result = std.jsonSchema[io]({ target: 'draft-2020-12', libraryOptions: { reused: 'inline' } }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. @@ -197,7 +202,13 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' ); } - result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record; + // See note above on line 181: pin `reused: 'inline'` for the zod 4.0–4.1 fallback + // path too, so older zod versions whose default was `'ref'` still produce inline + // schemas. Fixes #2100. + result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io, reused: 'inline' }) as Record< + string, + unknown + >; } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index 6c3de99d77..a2530063dd 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -1,6 +1,23 @@ import * as z from 'zod/v4'; -import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; +import { standardSchemaToJsonSchema, type StandardJSONSchemaV1 } from '../../src/util/standardSchema.js'; + +/** + * Walk a JSON Schema-shaped value and collect every `$ref` string it contains. + * Used to assert the SDK never emits `$ref` keys regardless of nesting depth. + */ +function collectRefs(value: unknown, refs: string[] = []): string[] { + if (value == null || typeof value !== 'object') return refs; + if (Array.isArray(value)) { + for (const v of value) collectRefs(v, refs); + return refs; + } + for (const [k, v] of Object.entries(value as Record)) { + if (k === '$ref' && typeof v === 'string') refs.push(v); + collectRefs(v, refs); + } + return refs; +} describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { @@ -39,4 +56,59 @@ describe('standardSchemaToJsonSchema', () => { expect(keys.filter(k => k === 'type')).toHaveLength(1); expect(result.type).toBe('object'); }); + + describe('$ref behavior for reused schemas (issue #2100)', () => { + test('inlines an anonymous reused object referenced by two fields', () => { + const Address = z.object({ street: z.string(), city: z.string() }); + const schema = z.object({ shipping: Address, billing: Address }); + + const result = standardSchemaToJsonSchema(schema, 'input'); + + expect(collectRefs(result)).toEqual([]); + }); + + test('inlines a reused object referenced inside an array', () => { + const Address = z.object({ street: z.string(), city: z.string() }); + const schema = z.object({ primary: Address, history: z.array(Address) }); + + const result = standardSchemaToJsonSchema(schema, 'input'); + + expect(collectRefs(result)).toEqual([]); + }); + + test('inlines reused objects inside a discriminated union', () => { + const Address = z.object({ street: z.string(), city: z.string() }); + const schema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('ship'), address: Address }), + z.object({ kind: z.literal('bill'), address: Address }) + ]); + + const result = standardSchemaToJsonSchema(schema, 'input'); + + expect(collectRefs(result)).toEqual([]); + }); + + test('control: forcing reused:ref via libraryOptions does produce $refs (proves SDK pins inline)', () => { + // Defensive lock-in: even if a caller bypasses the SDK and forces zod into + // ref-mode, we want a witness that ref-mode is in fact reachable. This + // guards against the test above passing accidentally because zod silently + // dropped support for the option. + const Address = z.object({ street: z.string(), city: z.string() }); + const schema = z.object({ shipping: Address, billing: Address }); + + // Reach into the underlying StandardJSONSchemaV1 converter and pass the + // ref-mode override directly. The SDK never threads this knob through, so + // this only affects the witness call below. + const std = (schema as unknown as StandardJSONSchemaV1)['~standard']; + const refResult = std.jsonSchema.input({ + target: 'draft-2020-12', + libraryOptions: { reused: 'ref' } + }); + expect(collectRefs(refResult).length).toBeGreaterThan(0); + + // And the SDK's converter must still inline regardless. + const sdkResult = standardSchemaToJsonSchema(schema, 'input'); + expect(collectRefs(sdkResult)).toEqual([]); + }); + }); }); diff --git a/packages/core/test/util/standardSchema.zodFallback.test.ts b/packages/core/test/util/standardSchema.zodFallback.test.ts index d825a3271d..1dcc035e52 100644 --- a/packages/core/test/util/standardSchema.zodFallback.test.ts +++ b/packages/core/test/util/standardSchema.zodFallback.test.ts @@ -34,4 +34,37 @@ describe('standardSchemaToJsonSchema — zod fallback paths', () => { expect(() => standardSchemaToJsonSchema(fake as unknown as SchemaArg)).toThrow(/mylib/); expect(() => standardSchemaToJsonSchema(fake as unknown as SchemaArg)).toThrow(/fromJsonSchema/); }); + + it('inlines reused subschemas on the zod-fallback path (issue #2100)', () => { + // The fallback path goes through z.toJSONSchema directly; verify the SDK + // forces `reused: 'inline'` there too, so older zod versions whose default + // was 'ref' still produce a $ref-free schema for strict MCP clients. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const Address = z.object({ street: z.string(), city: z.string() }); + const real = z.object({ shipping: Address, billing: Address }); + + // Simulate zod 4.0–4.1: drop `~standard.jsonSchema` so the fallback path fires. + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); + + // Walk the result and assert no `$ref` keys appear anywhere. + const refs: string[] = []; + const walk = (v: unknown) => { + if (v == null || typeof v !== 'object') return; + if (Array.isArray(v)) { + for (const x of v) walk(x); + return; + } + for (const [k, x] of Object.entries(v as Record)) { + if (k === '$ref' && typeof x === 'string') refs.push(x); + walk(x); + } + }; + walk(result); + expect(refs).toEqual([]); + warn.mockRestore(); + }); });