diff --git a/.changeset/fix-empty-object-schema-required-field.md b/.changeset/fix-empty-object-schema-required-field.md new file mode 100644 index 000000000..f25d941dc --- /dev/null +++ b/.changeset/fix-empty-object-schema-required-field.md @@ -0,0 +1,11 @@ +--- +"@modelcontextprotocol/core": patch +--- + +Fix JSON schema generation from empty Zod objects for OpenAI strict mode compatibility. + +When converting Zod schemas to JSON Schema, object schemas now always include the +`required` field, even when empty. This is necessary for compatibility with OpenAI's +strict JSON schema mode, which requires `required` to always be present. + +Fixes #1659. diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index adecee361..7b5146446 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -24,9 +24,99 @@ export type SchemaOutput = z.output; /** * Converts a Zod schema to JSON Schema. + * + * This function ensures that object schemas always include the `required` field, + * even when empty. This is necessary for compatibility with OpenAI's strict + * JSON schema mode, which requires `required` to always be present. + * + * @see https://github.com/modelcontextprotocol/typescript-sdk/issues/1659 */ 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 ensureRequiredField(jsonSchema); +} + +/** + * Recursively ensures that all object schemas have a `required` field. + * This is needed for OpenAI strict JSON schema compatibility. + * + * Creates a new object rather than mutating in-place to avoid side effects + * if the input schema is cached or reused. + */ +function ensureRequiredField(schema: Record): Record { + // Create a shallow copy to avoid mutating the original + const result = { ...schema }; + + // If this is an object type without a required field, add an empty one + if (result.type === 'object' && !('required' in result)) { + result.required = []; + } + + // Process nested properties recursively + if (result.properties && typeof result.properties === 'object') { + const newProperties: Record = {}; + for (const key of Object.keys(result.properties)) { + const prop = (result.properties as Record)[key]; + if (prop && typeof prop === 'object') { + newProperties[key] = ensureRequiredField(prop as Record); + } else { + newProperties[key] = prop; + } + } + result.properties = newProperties; + } + + // Process additionalProperties if it's a schema + if (result.additionalProperties && typeof result.additionalProperties === 'object') { + result.additionalProperties = ensureRequiredField(result.additionalProperties as Record); + } + + // Process items for arrays + if (result.items && typeof result.items === 'object') { + result.items = ensureRequiredField(result.items as Record); + } + + // Process prefixItems for tuple schemas (JSON Schema 2020-12) + if (Array.isArray(result.prefixItems)) { + result.prefixItems = (result.prefixItems as Record[]).map(s => + s && typeof s === 'object' ? ensureRequiredField(s) : s + ); + } + + // Process allOf, anyOf, oneOf combiners + for (const combiner of ['allOf', 'anyOf', 'oneOf'] as const) { + if (Array.isArray(result[combiner])) { + result[combiner] = (result[combiner] as Record[]).map(s => ensureRequiredField(s)); + } + } + + // Process 'not' schema + if (result.not && typeof result.not === 'object') { + result.not = ensureRequiredField(result.not as Record); + } + + // Process conditional schemas (if/then/else) + for (const conditional of ['if', 'then', 'else'] as const) { + if (result[conditional] && typeof result[conditional] === 'object') { + result[conditional] = ensureRequiredField(result[conditional] as Record); + } + } + + // Process $defs for referenced schemas + if (result.$defs && typeof result.$defs === 'object') { + const newDefs: Record = {}; + for (const key of Object.keys(result.$defs)) { + const def = (result.$defs as Record)[key]; + if (def && typeof def === 'object') { + newDefs[key] = ensureRequiredField(def as Record); + } else { + newDefs[key] = def; + } + } + result.$defs = newDefs; + } + + return result; } /** diff --git a/packages/core/test/util/schema.test.ts b/packages/core/test/util/schema.test.ts new file mode 100644 index 000000000..27e7fbda5 --- /dev/null +++ b/packages/core/test/util/schema.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; +import { schemaToJson } from '../../src/util/schema.js'; + +describe('schemaToJson', () => { + describe('required field handling for OpenAI compatibility', () => { + // https://github.com/modelcontextprotocol/typescript-sdk/issues/1659 + it('should include empty required array for empty object schemas', () => { + const schema = z.object({}).strict(); + const jsonSchema = schemaToJson(schema); + + expect(jsonSchema.type).toBe('object'); + expect(jsonSchema.required).toEqual([]); + }); + + it('should include empty required array for objects with only optional properties', () => { + const schema = z.object({ + name: z.string().optional() + }); + const jsonSchema = schemaToJson(schema); + + expect(jsonSchema.type).toBe('object'); + expect(jsonSchema.required).toEqual([]); + }); + + it('should preserve required array for objects with required properties', () => { + const schema = z.object({ + name: z.string(), + age: z.number().optional() + }); + const jsonSchema = schemaToJson(schema); + + expect(jsonSchema.type).toBe('object'); + expect(jsonSchema.required).toEqual(['name']); + }); + + it('should add required field to nested object schemas', () => { + const schema = z.object({ + nested: z.object({}).strict() + }); + const jsonSchema = schemaToJson(schema); + + const nestedSchema = (jsonSchema.properties as Record).nested as Record; + expect(nestedSchema.type).toBe('object'); + expect(nestedSchema.required).toEqual([]); + }); + + it('should add required field to array item schemas', () => { + const schema = z.array(z.object({}).strict()); + const jsonSchema = schemaToJson(schema); + + const itemsSchema = jsonSchema.items as Record; + expect(itemsSchema.type).toBe('object'); + expect(itemsSchema.required).toEqual([]); + }); + + it('should add required field to deeply nested object schemas', () => { + const schema = z.object({ + level1: z.object({ + level2: z.object({ + level3: z.object({}).strict() + }) + }) + }); + const jsonSchema = schemaToJson(schema); + + const level1 = (jsonSchema.properties as Record).level1 as Record; + const level2 = (level1.properties as Record).level2 as Record; + const level3 = (level2.properties as Record).level3 as Record; + + expect(level3.type).toBe('object'); + expect(level3.required).toEqual([]); + }); + + it('should add required field to union schemas (anyOf)', () => { + const schema = z.union([ + z.object({}).strict(), + z.object({ name: z.string().optional() }) + ]); + const jsonSchema = schemaToJson(schema); + + // Union creates an anyOf schema + expect(jsonSchema.anyOf).toBeDefined(); + const variants = jsonSchema.anyOf as Record[]; + + // Both variants should have required field + for (const variant of variants) { + if (variant.type === 'object') { + expect(variant.required).toBeDefined(); + expect(Array.isArray(variant.required)).toBe(true); + } + } + }); + + it('should not mutate the original schema', () => { + const schema = z.object({}).strict(); + const originalJsonSchema = z.toJSONSchema(schema) as Record; + const hasRequiredBefore = 'required' in originalJsonSchema; + + // Call schemaToJson + schemaToJson(schema); + + // Original should not be mutated + const hasRequiredAfter = 'required' in originalJsonSchema; + expect(hasRequiredBefore).toBe(hasRequiredAfter); + }); + }); +});