diff --git a/.changeset/fix-elicit-primitive-passthrough.md b/.changeset/fix-elicit-primitive-passthrough.md new file mode 100644 index 000000000..05ac965f0 --- /dev/null +++ b/.changeset/fix-elicit-primitive-passthrough.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Allow extra JSON Schema keywords on elicitation primitive schemas (`string`, `number`, `boolean`). + +Previously, `BooleanSchemaSchema`, `StringSchemaSchema`, and `NumberSchemaSchema` used strict Zod parsing, so any keyword not explicitly listed (e.g., `pattern`, `exclusiveMinimum`, `exclusiveMaximum`, `const`) caused the schema to be rejected. This broke real-world use cases where servers send valid JSON Schema with standard annotation or validation keywords. + +The fix adds `.passthrough()` to each primitive schema so that extra keys are preserved instead of stripped. The corresponding `BooleanSchema`, `StringSchema`, and `NumberSchema` TypeScript interfaces also gain `[key: string]: unknown` index signatures to stay in sync. diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 86acf11d7..b1f1d9da5 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1717,37 +1717,43 @@ export const CreateMessageResultWithToolsSchema = ResultSchema.extend({ /** * Primitive schema definition for boolean fields. */ -export const BooleanSchemaSchema = z.object({ - type: z.literal('boolean'), - title: z.string().optional(), - description: z.string().optional(), - default: z.boolean().optional() -}); +export const BooleanSchemaSchema = z + .object({ + type: z.literal('boolean'), + title: z.string().optional(), + description: z.string().optional(), + default: z.boolean().optional() + }) + .passthrough(); /** * Primitive schema definition for string fields. */ -export const StringSchemaSchema = z.object({ - type: z.literal('string'), - title: z.string().optional(), - description: z.string().optional(), - minLength: z.number().optional(), - maxLength: z.number().optional(), - format: z.enum(['email', 'uri', 'date', 'date-time']).optional(), - default: z.string().optional() -}); +export const StringSchemaSchema = z + .object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + format: z.enum(['email', 'uri', 'date', 'date-time']).optional(), + default: z.string().optional() + }) + .passthrough(); /** * Primitive schema definition for number fields. */ -export const NumberSchemaSchema = z.object({ - type: z.enum(['number', 'integer']), - title: z.string().optional(), - description: z.string().optional(), - minimum: z.number().optional(), - maximum: z.number().optional(), - default: z.number().optional() -}); +export const NumberSchemaSchema = z + .object({ + type: z.enum(['number', 'integer']), + title: z.string().optional(), + description: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + default: z.number().optional() + }) + .passthrough(); /** * Schema for single-selection enumeration without display titles for options. diff --git a/packages/core/src/types/spec.types.ts b/packages/core/src/types/spec.types.ts index a03f21f13..367dabf9e 100644 --- a/packages/core/src/types/spec.types.ts +++ b/packages/core/src/types/spec.types.ts @@ -2875,6 +2875,7 @@ export type PrimitiveSchemaDefinition = StringSchema | NumberSchema | BooleanSch * @category `elicitation/create` */ export interface StringSchema { + [key: string]: unknown; type: 'string'; title?: string; description?: string; @@ -2891,6 +2892,7 @@ export interface StringSchema { * @category `elicitation/create` */ export interface NumberSchema { + [key: string]: unknown; type: 'number' | 'integer'; title?: string; description?: string; @@ -2906,6 +2908,7 @@ export interface NumberSchema { * @category `elicitation/create` */ export interface BooleanSchema { + [key: string]: unknown; type: 'boolean'; title?: string; description?: string; diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts index 55c989da0..1e65e2911 100644 --- a/test/integration/test/server/elicitation.test.ts +++ b/test/integration/test/server/elicitation.test.ts @@ -161,7 +161,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo age: { type: 'integer', minimum: 0, maximum: 150 }, street: { type: 'string' }, city: { type: 'string' }, - // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator zipCode: { type: 'string', pattern: '^[0-9]{5}$' }, newsletter: { type: 'boolean' }, notifications: { type: 'boolean' } @@ -280,7 +279,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo requestedSchema: { type: 'object', properties: { - // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator zipCode: { type: 'string', pattern: '^[0-9]{5}$' } }, required: ['zipCode'] @@ -290,6 +288,69 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/); }); + test(`${validatorName}: should accept extra JSON Schema keys on string primitive (pattern)`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { code: 'abc' } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Enter a code', + requestedSchema: { + type: 'object', + properties: { + code: { type: 'string', pattern: '^[a-z]+$' } + }, + required: ['code'] + } + }); + + expect(result).toEqual({ action: 'accept', content: { code: 'abc' } }); + }); + + test(`${validatorName}: should accept extra JSON Schema keys on number primitive (exclusiveMinimum)`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { score: 0.5 } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Enter a score', + requestedSchema: { + type: 'object', + properties: { + score: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 1 } + }, + required: ['score'] + } + }); + + expect(result).toEqual({ action: 'accept', content: { score: 0.5 } }); + }); + + test(`${validatorName}: should accept extra JSON Schema keys on boolean primitive (const)`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { agreed: true } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Do you agree?', + requestedSchema: { + type: 'object', + properties: { + agreed: { type: 'boolean', const: true } + }, + required: ['agreed'] + } + }); + + expect(result).toEqual({ action: 'accept', content: { agreed: true } }); + }); + test(`${validatorName}: should allow decline action without validation`, async () => { client.setRequestHandler('elicitation/create', _request => ({ action: 'decline'