From 788a86a212fa3d138c0423ef2f6e79b5d49f8489 Mon Sep 17 00:00:00 2001 From: Joe Wanko Date: Wed, 18 Mar 2026 18:03:51 -0400 Subject: [PATCH 1/2] fix: ensure empty object schemas include required field for OpenAI compatibility When generating JSON Schema from Zod schemas via schemaToJson(), object schemas now always include the 'required' field, even when empty. This is necessary for compatibility with OpenAI's strict JSON schema mode, which mandates that 'required' always be present. The fix recursively processes all nested object schemas, including those in properties, array items, additionalProperties, allOf/anyOf/oneOf, and $defs. Fixes #1659. --- .../fix-empty-object-schema-required-field.md | 11 +++ packages/core/src/util/schema.ts | 59 ++++++++++++++- packages/core/test/util/schema.test.ts | 75 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-empty-object-schema-required-field.md create mode 100644 packages/core/test/util/schema.test.ts 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..8c6b00764 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -24,9 +24,66 @@ 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. + */ +function ensureRequiredField(schema: Record): Record { + // If this is an object type without a required field, add an empty one + if (schema.type === 'object' && !('required' in schema)) { + schema.required = []; + } + + // Process nested properties recursively + if (schema.properties && typeof schema.properties === 'object') { + for (const key of Object.keys(schema.properties)) { + const prop = (schema.properties as Record)[key]; + if (prop && typeof prop === 'object') { + (schema.properties as Record)[key] = ensureRequiredField(prop as Record); + } + } + } + + // Process additionalProperties if it's a schema + if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { + schema.additionalProperties = ensureRequiredField(schema.additionalProperties as Record); + } + + // Process items for arrays + if (schema.items && typeof schema.items === 'object') { + schema.items = ensureRequiredField(schema.items as Record); + } + + // Process allOf, anyOf, oneOf + for (const combiner of ['allOf', 'anyOf', 'oneOf'] as const) { + if (Array.isArray(schema[combiner])) { + schema[combiner] = (schema[combiner] as Record[]).map(s => ensureRequiredField(s)); + } + } + + // Process $defs for referenced schemas + if (schema.$defs && typeof schema.$defs === 'object') { + for (const key of Object.keys(schema.$defs)) { + const def = (schema.$defs as Record)[key]; + if (def && typeof def === 'object') { + (schema.$defs as Record)[key] = ensureRequiredField(def as Record); + } + } + } + + return schema; } /** diff --git a/packages/core/test/util/schema.test.ts b/packages/core/test/util/schema.test.ts new file mode 100644 index 000000000..b9514dbaf --- /dev/null +++ b/packages/core/test/util/schema.test.ts @@ -0,0 +1,75 @@ +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([]); + }); + }); +}); From cc6a6639590aa48906b92913f22b5d24b600b88f Mon Sep 17 00:00:00 2001 From: Owen Devereaux Date: Thu, 19 Mar 2026 09:57:58 -0400 Subject: [PATCH 2/2] Address review feedback: immutable + additional recursive cases - Create shallow copy instead of mutating in-place (avoids side effects if schema is cached/reused) - Add recursive handling for prefixItems (JSON Schema 2020-12 tuples) - Add recursive handling for 'not' schema - Add recursive handling for conditional schemas (if/then/else) - Add test for union schemas (anyOf) to cover combiner paths - Add test verifying original schema is not mutated Addresses feedback from @travisbreaks on PR #1702 --- packages/core/src/util/schema.ts | 69 +++++++++++++++++++------- packages/core/test/util/schema.test.ts | 33 ++++++++++++ 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index 8c6b00764..7b5146446 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -39,51 +39,84 @@ export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'outp /** * 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 (schema.type === 'object' && !('required' in schema)) { - schema.required = []; + if (result.type === 'object' && !('required' in result)) { + result.required = []; } // Process nested properties recursively - if (schema.properties && typeof schema.properties === 'object') { - for (const key of Object.keys(schema.properties)) { - const prop = (schema.properties as Record)[key]; + 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') { - (schema.properties as Record)[key] = ensureRequiredField(prop as Record); + newProperties[key] = ensureRequiredField(prop as Record); + } else { + newProperties[key] = prop; } } + result.properties = newProperties; } // Process additionalProperties if it's a schema - if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { - schema.additionalProperties = ensureRequiredField(schema.additionalProperties as Record); + if (result.additionalProperties && typeof result.additionalProperties === 'object') { + result.additionalProperties = ensureRequiredField(result.additionalProperties as Record); } // Process items for arrays - if (schema.items && typeof schema.items === 'object') { - schema.items = ensureRequiredField(schema.items as Record); + 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 + // Process allOf, anyOf, oneOf combiners for (const combiner of ['allOf', 'anyOf', 'oneOf'] as const) { - if (Array.isArray(schema[combiner])) { - schema[combiner] = (schema[combiner] as Record[]).map(s => ensureRequiredField(s)); + 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 (schema.$defs && typeof schema.$defs === 'object') { - for (const key of Object.keys(schema.$defs)) { - const def = (schema.$defs as Record)[key]; + 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') { - (schema.$defs as Record)[key] = ensureRequiredField(def as Record); + newDefs[key] = ensureRequiredField(def as Record); + } else { + newDefs[key] = def; } } + result.$defs = newDefs; } - return schema; + return result; } /** diff --git a/packages/core/test/util/schema.test.ts b/packages/core/test/util/schema.test.ts index b9514dbaf..27e7fbda5 100644 --- a/packages/core/test/util/schema.test.ts +++ b/packages/core/test/util/schema.test.ts @@ -71,5 +71,38 @@ describe('schemaToJson', () => { 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); + }); }); });