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-reused-zod-schemas.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 13 additions & 2 deletions packages/core/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,12 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
const std = schema['~standard'];
let result: Record<string, unknown>;
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.
Expand All @@ -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<string, unknown>;
// 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\`). ` +
Expand Down
74 changes: 73 additions & 1 deletion packages/core/test/util/standardSchema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)) {
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', () => {
Expand Down Expand Up @@ -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([]);
});
});
});
33 changes: 33 additions & 0 deletions packages/core/test/util/standardSchema.zodFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>)) {
if (k === '$ref' && typeof x === 'string') refs.push(x);
walk(x);
}
};
walk(result);
expect(refs).toEqual([]);
warn.mockRestore();
});
});
Loading