diff --git a/.changeset/schema-object-type-for-unions.md b/.changeset/schema-object-type-for-unions.md new file mode 100644 index 000000000..7749bb6c5 --- /dev/null +++ b/.changeset/schema-object-type-for-unions.md @@ -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. diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index d95572c0e..9817dc39a 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -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 { - 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 diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts new file mode 100644 index 000000000..6c3de99d7 --- /dev/null +++ b/packages/core/test/util/standardSchema.test.ts @@ -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'); + }); +});