fix(core): ensure standardSchemaToJsonSchema emits type:object (#1796)

This commit is contained in:
Felix Weinberger 2026-03-30 21:41:23 +01:00 • committed by GitHub
commit d6a02c85c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 65 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
'@modelcontextprotocol/core': patch
---
Ensure `standardSchemaToJsonSchema` emits `type: "object"` at the root, fixing discriminated-union tool/prompt schemas that previously produced `{oneOf: [...]}` without the MCP-required top-level type. Also throws a clear error when given an explicitly non-object schema (e.g. `z.string()`). Fixes #1643.

View file

@ -138,8 +138,25 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch
// JSON Schema conversion
/**
* Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema.
*
* MCP requires `type: "object"` at the root of tool inputSchema/outputSchema and
* prompt argument schemas. Zod's discriminated unions emit `{oneOf: [...]}` without
* a top-level `type`, so this function defaults `type` to `"object"` when absent.
*
* Throws if the schema has an explicit non-object `type` (e.g. `z.string()`),
* since that cannot satisfy the MCP spec.
*/
export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record<string, unknown> {
return schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' });
const result = schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' });
if (result.type !== undefined && result.type !== 'object') {
throw new Error(
`MCP tool and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` +
`Wrap your schema in z.object({...}) or equivalent.`
);
}
return { type: 'object', ...result };
}
// Validation

View file

@ -0,0 +1,42 @@
import * as z from 'zod/v4';
import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';
describe('standardSchemaToJsonSchema', () => {
test('emits type:object for plain z.object schemas', () => {
const schema = z.object({ name: z.string(), age: z.number() });
const result = standardSchemaToJsonSchema(schema, 'input');
expect(result.type).toBe('object');
expect(result.properties).toBeDefined();
});
test('emits type:object for discriminated unions', () => {
const schema = z.discriminatedUnion('action', [
z.object({ action: z.literal('create'), name: z.string() }),
z.object({ action: z.literal('delete'), id: z.string() })
]);
const result = standardSchemaToJsonSchema(schema, 'input');
expect(result.type).toBe('object');
// Zod emits oneOf for discriminated unions; the catchall on Tool.inputSchema
// accepts it, but the top-level type must be present per MCP spec.
expect(result.oneOf ?? result.anyOf).toBeDefined();
});
test('throws for schemas with explicit non-object type', () => {
expect(() => standardSchemaToJsonSchema(z.string(), 'input')).toThrow(/must describe objects/);
expect(() => standardSchemaToJsonSchema(z.array(z.string()), 'input')).toThrow(/must describe objects/);
expect(() => standardSchemaToJsonSchema(z.number(), 'input')).toThrow(/must describe objects/);
});
test('preserves existing type:object without modification', () => {
const schema = z.object({ x: z.string() });
const result = standardSchemaToJsonSchema(schema, 'input');
// Spread order means zod's own type:"object" wins; verify no double-wrap.
const keys = Object.keys(result);
expect(keys.filter(k => k === 'type')).toHaveLength(1);
expect(result.type).toBe('object');
});
});