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
5 changes: 5 additions & 0 deletions .changeset/schema-object-type-for-unions.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 18 additions & 1 deletion packages/core/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions packages/core/test/util/standardSchema.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading