From e5bc735bb48e396a4138233b55ed7fefe7f3aaff Mon Sep 17 00:00:00 2001 From: XinLei Date: Wed, 11 Mar 2026 23:07:44 -0700 Subject: [PATCH] fix: add required:[] to empty object JSON schemas for OpenAI strict mode When converting Zod schemas to JSON Schema via schemaToJson(), empty objects (z.object({})) omit the required field. While valid JSON Schema, this breaks OpenAI strict JSON schema mode which requires required to always be present (even as an empty array). Fix: add a normalizeEmptyObjectRequired() post-processing step that recursively adds required: [] to any object schema with properties but no required. Skips literal data fields (default, const, enum, example, examples) to avoid mutating non-schema values. Fixes #1659 Co-Authored-By: Claude Opus 4.6 --- packages/core/src/util/schema.ts | 27 +++++++- packages/core/test/schema.test.ts | 55 +++++++++++++++++ test/integration/test/server/mcp.test.ts | 78 +++++++++++++++++++++++- 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/schema.test.ts diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index adecee361..947a42a7b 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -26,7 +26,32 @@ export type SchemaOutput = z.output; * Converts a Zod schema to JSON Schema. */ export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record { - return z.toJSONSchema(schema, options) as Record; + return normalizeEmptyObjectRequired(z.toJSONSchema(schema, options) as Record) as Record; +} + +const LITERAL_JSON_SCHEMA_VALUE_KEYS = new Set(['const', 'default', 'enum', 'example', 'examples']); + +function normalizeEmptyObjectRequired(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(item => normalizeEmptyObjectRequired(item)); + } + + if (!value || typeof value !== 'object') { + return value; + } + + const normalized = Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + LITERAL_JSON_SCHEMA_VALUE_KEYS.has(key) ? child : normalizeEmptyObjectRequired(child) + ]) + ); + + if (normalized.type === 'object' && Object.hasOwn(normalized, 'properties') && !Object.hasOwn(normalized, 'required')) { + normalized.required = []; + } + + return normalized; } /** diff --git a/packages/core/test/schema.test.ts b/packages/core/test/schema.test.ts new file mode 100644 index 000000000..697eb6d93 --- /dev/null +++ b/packages/core/test/schema.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { schemaToJson } from '../src/util/schema.js'; + +describe('schemaToJson', () => { + test('adds empty required arrays for empty object schemas recursively', () => { + const schema = z.object({ + nested: z.object({}).strict(), + configured: z.object({ + name: z.string() + }) + }); + + expect(schemaToJson(schema)).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + nested: { + type: 'object', + properties: {}, + required: [], + additionalProperties: false + }, + configured: { + type: 'object', + properties: { + name: { + type: 'string' + } + }, + required: ['name'], + additionalProperties: false + } + }, + required: ['nested', 'configured'], + additionalProperties: false + }); + }); + + test('does not normalize literal values in default fields', () => { + const schema = z.any().default({ + type: 'object', + properties: {} + }); + + expect(schemaToJson(schema)).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + default: { + type: 'object', + properties: {} + } + }); + }); +}); diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 416f05102..ba2b9d9ab 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -883,7 +883,83 @@ describe('Zod v4', () => { properties: { name: { type: 'string' }, value: { type: 'number' } - } + }, + required: ['name', 'value'] + }); + }); + + test('should include required arrays for empty object schemas in tools/list', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'empty-input', + { + inputSchema: z.object({}), + outputSchema: z.object({ + nested: z.object({}).strict() + }) + }, + async () => ({ + structuredContent: { nested: {} }, + content: [{ type: 'text', text: 'ok' }] + }) + ); + + mcpServer.registerTool( + 'with-field', + { + inputSchema: z.object({ fieldName: z.string() }) + }, + async ({ fieldName }) => ({ + content: [{ type: 'text', text: fieldName }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/list' + }); + + const emptyInputTool = result.tools.find(tool => tool.name === 'empty-input'); + expect(emptyInputTool).toBeDefined(); + expect(emptyInputTool!.inputSchema).toMatchObject({ + type: 'object', + properties: {}, + required: [] + }); + expect(emptyInputTool!.outputSchema).toMatchObject({ + type: 'object', + properties: { + nested: { + type: 'object', + properties: {}, + required: [], + additionalProperties: false + } + }, + required: ['nested'] + }); + + const fieldTool = result.tools.find(tool => tool.name === 'with-field'); + expect(fieldTool).toBeDefined(); + expect(fieldTool!.inputSchema).toMatchObject({ + type: 'object', + properties: { + fieldName: { + type: 'string' + } + }, + required: ['fieldName'] }); });