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
11 changes: 11 additions & 0 deletions .changeset/fix-empty-object-schema-required-field.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 91 additions & 1 deletion packages/core/src/util/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,99 @@ export type SchemaOutput<T extends AnySchema> = z.output<T>;

/**
* 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<string, unknown> {
return z.toJSONSchema(schema, options) as Record<string, unknown>;
const jsonSchema = z.toJSONSchema(schema, options) as Record<string, unknown>;
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<string, unknown>): Record<string, unknown> {
// 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<string, unknown> = {};
for (const key of Object.keys(result.properties)) {
const prop = (result.properties as Record<string, unknown>)[key];
if (prop && typeof prop === 'object') {
newProperties[key] = ensureRequiredField(prop as Record<string, unknown>);
} 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<string, unknown>);
}

// Process items for arrays
if (result.items && typeof result.items === 'object') {
result.items = ensureRequiredField(result.items as Record<string, unknown>);
}

// Process prefixItems for tuple schemas (JSON Schema 2020-12)
if (Array.isArray(result.prefixItems)) {
result.prefixItems = (result.prefixItems as Record<string, unknown>[]).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<string, unknown>[]).map(s => ensureRequiredField(s));
}
}

// Process 'not' schema
if (result.not && typeof result.not === 'object') {
result.not = ensureRequiredField(result.not as Record<string, unknown>);
}

// 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<string, unknown>);
}
}

// Process $defs for referenced schemas
if (result.$defs && typeof result.$defs === 'object') {
const newDefs: Record<string, unknown> = {};
for (const key of Object.keys(result.$defs)) {
const def = (result.$defs as Record<string, unknown>)[key];
if (def && typeof def === 'object') {
newDefs[key] = ensureRequiredField(def as Record<string, unknown>);
} else {
newDefs[key] = def;
}
}
result.$defs = newDefs;
}

return result;
}

/**
Expand Down
108 changes: 108 additions & 0 deletions packages/core/test/util/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).nested as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>).level1 as Record<string, unknown>;
const level2 = (level1.properties as Record<string, unknown>).level2 as Record<string, unknown>;
const level3 = (level2.properties as Record<string, unknown>).level3 as Record<string, unknown>;

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<string, unknown>[];

// 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<string, unknown>;
const hasRequiredBefore = 'required' in originalJsonSchema;

// Call schemaToJson
schemaToJson(schema);

// Original should not be mutated
const hasRequiredAfter = 'required' in originalJsonSchema;
expect(hasRequiredBefore).toBe(hasRequiredAfter);
});
});
});