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
27 changes: 26 additions & 1 deletion packages/core/src/util/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,32 @@ export type SchemaOutput<T extends AnySchema> = z.output<T>;
* Converts a Zod schema to JSON Schema.
*/
export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record<string, unknown> {
return z.toJSONSchema(schema, options) as Record<string, unknown>;
return normalizeEmptyObjectRequired(z.toJSONSchema(schema, options) as Record<string, unknown>) as Record<string, unknown>;
}

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;
}

/**
Expand Down
55 changes: 55 additions & 0 deletions packages/core/test/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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: {}
}
});
});
});
78 changes: 77 additions & 1 deletion test/integration/test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
});
});

Expand Down
Loading