fix(core): ensure standardSchemaToJsonSchema emits type:object (#1796)
This commit is contained in:
parent
a39a9eb485
commit
d6a02c85c0
3 changed files with 65 additions and 1 deletions
5
.changeset/schema-object-type-for-unions.md
Normal file
5
.changeset/schema-object-type-for-unions.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
42
packages/core/test/util/standardSchema.test.ts
Normal file
42
packages/core/test/util/standardSchema.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue