From 79dc1cd1316a7f0a1f31afc844e6230be526b34d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 14:45:33 +0200 Subject: [PATCH 1/8] feat(server): accept standard schemas for elicitation --- packages/core-internal/src/shared/protocol.ts | 22 +++++- packages/server/src/server/server.ts | 50 +++++++++++-- .../jsonSchemaValidatorOverride.test.ts | 70 +++++++++++++++++++ 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 3b7efec2a..a9f961a34 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -42,7 +42,7 @@ import { ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index'; -import type { StandardSchemaV1 } from '../util/standardSchema'; +import type { StandardSchemaV1, StandardSchemaWithJSON } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; import type { Transport, TransportSendOptions } from './transport'; @@ -203,6 +203,18 @@ export type BaseContext = { }; }; +export type ElicitInputFormParams = Omit< + ElicitRequestFormParams, + 'requestedSchema' +> & { + requestedSchema: Schema; +}; + +export type ElicitInputResult = Result & { + action: ElicitResult['action']; + content?: StandardSchemaWithJSON.InferOutput; +}; + /** * Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields. */ @@ -221,7 +233,13 @@ export type ServerContext = BaseContext & { /** * Send an elicitation request to the client, requesting user input. */ - elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; + elicitInput: { + ( + params: ElicitInputFormParams, + options?: RequestOptions + ): Promise>; + (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; + }; /** * Request LLM sampling from the client. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index b0777d118..65ac7bf9e 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -6,6 +6,8 @@ import type { CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + ElicitInputFormParams, + ElicitInputResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, @@ -28,6 +30,7 @@ import type { Result, ServerCapabilities, ServerContext, + StandardSchemaWithJSON, ToolResultContent, ToolUseContent } from '@modelcontextprotocol/core-internal'; @@ -47,7 +50,8 @@ import { ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode + SdkErrorCode, + standardSchemaToJsonSchema } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -151,7 +155,7 @@ export class Server extends Protocol { // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), - elicitInput: (params, options) => this.elicitInput(params, options), + elicitInput: this.elicitInput.bind(this) as ServerContext['mcpReq']['elicitInput'], requestSampling: (params, options) => this.createMessage(params, options) }, http: hasHttpInfo @@ -525,7 +529,15 @@ export class Server extends Protocol { * @param options Optional request options. * @returns The result of the elicitation request. */ - async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + async elicitInput( + params: ElicitInputFormParams, + options?: RequestOptions + ): Promise>; + async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; + async elicitInput( + params: ElicitRequestFormParams | ElicitRequestURLParams | ElicitInputFormParams, + options?: RequestOptions + ): Promise { const mode = (params.mode ?? 'form') as 'form' | 'url'; switch (mode) { @@ -542,8 +554,9 @@ export class Server extends Protocol { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); } - const formParams: ElicitRequestFormParams = - params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; + const formParams = this.normalizeElicitInputFormParams( + params as ElicitRequestFormParams | ElicitInputFormParams + ); const result = await this._requestWithSchema( { method: 'elicitation/create', params: formParams }, @@ -577,6 +590,33 @@ export class Server extends Protocol { } } + private normalizeElicitInputFormParams( + params: ElicitRequestFormParams | ElicitInputFormParams + ): ElicitRequestFormParams { + const formParams = + params.mode === 'form' + ? (params as ElicitRequestFormParams) + : { ...(params as ElicitRequestFormParams), mode: 'form' as const }; + + if (this.isElicitInputSchema(formParams.requestedSchema)) { + return { + ...formParams, + requestedSchema: standardSchemaToJsonSchema( + formParams.requestedSchema, + 'input' + ) as ElicitRequestFormParams['requestedSchema'] + }; + } + + return formParams; + } + + private isElicitInputSchema( + schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON + ): schema is StandardSchemaWithJSON { + return typeof schema === 'object' && schema !== null && '~standard' in schema; + } + /** * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` * notification for the specified elicitation ID. diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index e5edf1839..993eed747 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -1,5 +1,7 @@ import type { JsonSchemaType, JsonSchemaValidatorResult, jsonSchemaValidator } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { expectTypeOf } from 'vitest'; +import * as z from 'zod/v4'; import { fromJsonSchema } from '../../src/fromJsonSchema'; import { Server } from '../../src/server/server'; @@ -80,6 +82,74 @@ describe('server JSON Schema validator overrides', () => { await clientTransport.close(); }); + test('Server elicitInput accepts a Standard Schema requestedSchema', async () => { + const validator = new RecordingValidator(); + const server = new Server( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: validator + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await clientTransport.start(); + + const initializeResponse = new Promise(resolve => { + clientTransport.onmessage = message => resolve(message); + }); + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { elicitation: { form: {} } }, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + }); + await initializeResponse; + + let requestedSchema: JsonSchemaType | undefined; + clientTransport.onmessage = async message => { + if ('method' in message && 'id' in message && message.method === 'elicitation/create' && message.params) { + requestedSchema = message.params.requestedSchema as JsonSchemaType; + await clientTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { action: 'accept', content: { name: 'Ada Lovelace' } } + }); + } + }; + + const schema = z.object({ + name: z.string().describe('Full name'), + subscribe: z.boolean().optional() + }); + + const result = await server.elicitInput({ + message: 'What is your name?', + requestedSchema: schema + }); + + expectTypeOf(result.content).toEqualTypeOf<{ name: string; subscribe?: boolean | undefined } | undefined>(); + expect(result).toEqual({ action: 'accept', content: { name: 'Ada Lovelace' } }); + expect(requestedSchema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string', description: 'Full name' }, + subscribe: { type: 'boolean' } + }, + required: ['name'] + }); + expect(validator.schemas).toEqual([requestedSchema]); + expect(validator.values).toEqual([{ name: 'Ada Lovelace' }]); + + await server.close(); + await clientTransport.close(); + }); + test('fromJsonSchema uses an explicitly supplied custom validator', async () => { const validator = new RecordingValidator(); const schema: JsonSchemaType = { From c230f1a158ecb09219aa9532f83c30423f58f2ed Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:27:56 +0200 Subject: [PATCH 2/8] fix: validate standard schema elicitation --- .changeset/standard-schema-elicitation.md | 7 ++ docs/migration.md | 8 ++ docs/server.md | 20 ++--- examples/server/src/elicitationFormExample.ts | 47 +++------- examples/server/src/serverGuide.examples.ts | 17 +--- .../core-internal/src/exports/public/index.ts | 2 + packages/server/src/server/server.ts | 72 +++++++++------ .../jsonSchemaValidatorOverride.test.ts | 87 ++++++++++++++++--- 8 files changed, 157 insertions(+), 103 deletions(-) create mode 100644 .changeset/standard-schema-elicitation.md diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md new file mode 100644 index 000000000..597f104bc --- /dev/null +++ b/.changeset/standard-schema-elicitation.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +Allow form elicitation requests to accept Standard Schema values such as Zod objects for `requestedSchema`. The server converts these schemas to MCP's restricted elicitation JSON Schema before sending and parses accepted content with the original schema before returning typed +results. diff --git a/docs/migration.md b/docs/migration.md index 1b6062225..c8d764cd0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -693,10 +693,18 @@ server.setRequestHandler('tools/call', async (request, ctx) => { requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } }); + // Or pass a Standard Schema such as a Zod object for typed content. + const typedElicitResult = await ctx.mcpReq.elicitInput({ + message: 'Please provide details', + requestedSchema: z.object({ name: z.string() }) + }); + return { content: [{ type: 'text', text: 'done' }] }; }); ``` +Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. + These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers. ### Error hierarchy refactoring diff --git a/docs/server.md b/docs/server.md index 468bf0cb2..b3a2e93a4 100644 --- a/docs/server.md +++ b/docs/server.md @@ -497,6 +497,9 @@ Elicitation lets a tool handler request direct input from the user — form fiel > [!IMPORTANT] > Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. +For form elicitation, pass either the restricted JSON Schema shape used by the MCP wire protocol or a Standard Schema such as a Zod object. Standard Schemas are converted to the restricted elicitation JSON Schema before being sent, so they must describe a flat object with +primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. + Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: ```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_elicitation" @@ -510,19 +513,10 @@ server.registerTool( const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Please share your feedback:', - requestedSchema: { - type: 'object', - properties: { - rating: { - type: 'number', - title: 'Rating (1\u20135)', - minimum: 1, - maximum: 5 - }, - comment: { type: 'string', title: 'Comment' } - }, - required: ['rating'] - } + requestedSchema: z.object({ + rating: z.number().min(1).max(5).describe('Rating (1-5)'), + comment: z.string().optional().describe('Comment') + }) }); if (result.action === 'accept') { return { diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts index e059e8452..49c7babc1 100644 --- a/examples/server/src/elicitationFormExample.ts +++ b/examples/server/src/elicitationFormExample.ts @@ -13,6 +13,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; // Create a fresh MCP server per client connection to avoid shared state between clients. // The validator supports format validation (email, date, etc.) if ajv-formats is installed. @@ -38,51 +39,23 @@ const getServer = () => { }, async () => { try { + const registrationSchema = z.object({ + username: z.string().min(3).max(20).describe('Your desired username (3-20 characters)'), + email: z.string().email().describe('Your email address'), + password: z.string().min(8).describe('Your password (min 8 characters)'), + newsletter: z.boolean().default(false).describe('Subscribe to newsletter?') + }); + // Request user information through form elicitation const result = await mcpServer.server.elicitInput({ mode: 'form', message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your desired username (3-20 characters)', - minLength: 3, - maxLength: 20 - }, - email: { - type: 'string', - title: 'Email', - description: 'Your email address', - format: 'email' - }, - password: { - type: 'string', - title: 'Password', - description: 'Your password (min 8 characters)', - minLength: 8 - }, - newsletter: { - type: 'boolean', - title: 'Newsletter', - description: 'Subscribe to newsletter?', - default: false - } - }, - required: ['username', 'email', 'password'] - } + requestedSchema: registrationSchema }); // Handle the different possible actions if (result.action === 'accept' && result.content) { - const { username, email, newsletter } = result.content as { - username: string; - email: string; - password: string; - newsletter?: boolean; - }; + const { username, email, newsletter } = result.content; return { content: [ diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index ec9eed210..8aa8e80de 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -418,19 +418,10 @@ function registerTool_elicitation(server: McpServer) { const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Please share your feedback:', - requestedSchema: { - type: 'object', - properties: { - rating: { - type: 'number', - title: 'Rating (1\u20135)', - minimum: 1, - maximum: 5 - }, - comment: { type: 'string', title: 'Comment' } - }, - required: ['rating'] - } + requestedSchema: z.object({ + rating: z.number().min(1).max(5).describe('Rating (1-5)'), + comment: z.string().optional().describe('Comment') + }) }); if (result.action === 'accept') { return { diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 88d4942a7..a1572d891 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -44,6 +44,8 @@ export { getDisplayName } from '../../shared/metadataUtils'; export type { BaseContext, ClientContext, + ElicitInputFormParams, + ElicitInputResult, NotificationOptions, ProgressCallback, ProtocolOptions, diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 65ac7bf9e..dbf96b5ae 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -39,6 +39,7 @@ import { CallToolResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + ElicitRequestFormParamsSchema, ElicitResultSchema, EmptyResultSchema, LATEST_PROTOCOL_VERSION, @@ -51,7 +52,8 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, - standardSchemaToJsonSchema + standardSchemaToJsonSchema, + validateStandardSchema } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -537,7 +539,7 @@ export class Server extends Protocol { async elicitInput( params: ElicitRequestFormParams | ElicitRequestURLParams | ElicitInputFormParams, options?: RequestOptions - ): Promise { + ): Promise> { const mode = (params.mode ?? 'form') as 'form' | 'url'; switch (mode) { @@ -554,7 +556,7 @@ export class Server extends Protocol { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); } - const formParams = this.normalizeElicitInputFormParams( + const { params: formParams, standardSchema } = this.normalizeElicitInputFormParams( params as ElicitRequestFormParams | ElicitInputFormParams ); @@ -564,25 +566,36 @@ export class Server extends Protocol { options ); - if (result.action === 'accept' && result.content && formParams.requestedSchema) { - try { - const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); - const validationResult = validator(result.content); - - if (!validationResult.valid) { + if (result.action === 'accept' && result.content !== undefined && formParams.requestedSchema) { + if (standardSchema) { + const parsedContent = await validateStandardSchema(standardSchema, result.content); + if (!parsedContent.success) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + `Elicitation response content does not match requested schema: ${parsedContent.error}` ); } - } catch (error) { - if (error instanceof ProtocolError) { - throw error; + return { ...result, content: parsedContent.data }; + } else { + try { + const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); + const validationResult = validator(result.content); + + if (!validationResult.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof ProtocolError) { + throw error; + } + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` + ); } - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` - ); } } return result; @@ -590,25 +603,32 @@ export class Server extends Protocol { } } - private normalizeElicitInputFormParams( - params: ElicitRequestFormParams | ElicitInputFormParams - ): ElicitRequestFormParams { + private normalizeElicitInputFormParams(params: ElicitRequestFormParams | ElicitInputFormParams): { + params: ElicitRequestFormParams; + standardSchema?: StandardSchemaWithJSON; + } { const formParams = params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' as const }; if (this.isElicitInputSchema(formParams.requestedSchema)) { - return { + const standardSchema = formParams.requestedSchema; + const normalizedParams = { ...formParams, - requestedSchema: standardSchemaToJsonSchema( - formParams.requestedSchema, - 'input' - ) as ElicitRequestFormParams['requestedSchema'] + requestedSchema: standardSchemaToJsonSchema(standardSchema, 'input') }; + const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams); + if (!parsedParams.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums).' + ); + } + return { params: parsedParams.data, standardSchema }; } - return formParams; + return { params: formParams }; } private isElicitInputSchema( diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 993eed747..e9151bb8d 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -1,7 +1,13 @@ -import type { JsonSchemaType, JsonSchemaValidatorResult, jsonSchemaValidator } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; import { expectTypeOf } from 'vitest'; import * as z from 'zod/v4'; +import type { + ElicitInputFormParams, + ElicitInputResult, + JsonSchemaType, + JsonSchemaValidatorResult, + jsonSchemaValidator +} from '../../src/index'; import { fromJsonSchema } from '../../src/fromJsonSchema'; import { Server } from '../../src/server/server'; @@ -118,33 +124,86 @@ describe('server JSON Schema validator overrides', () => { await clientTransport.send({ jsonrpc: '2.0', id: message.id, - result: { action: 'accept', content: { name: 'Ada Lovelace' } } + result: { action: 'accept', content: { count: '5' } } }); } }; const schema = z.object({ - name: z.string().describe('Full name'), - subscribe: z.boolean().optional() + count: z.coerce.number().min(1), + newsletter: z.boolean().default(false) }); - const result = await server.elicitInput({ - message: 'What is your name?', + const params = { + message: 'How many registrations?', requestedSchema: schema - }); + } satisfies ElicitInputFormParams; + + const result = await server.elicitInput(params); - expectTypeOf(result.content).toEqualTypeOf<{ name: string; subscribe?: boolean | undefined } | undefined>(); - expect(result).toEqual({ action: 'accept', content: { name: 'Ada Lovelace' } }); + expectTypeOf(result).toMatchTypeOf>(); + expectTypeOf(result.content).toEqualTypeOf<{ count: number; newsletter: boolean } | undefined>(); + expect(result).toEqual({ action: 'accept', content: { count: 5, newsletter: false } }); expect(requestedSchema).toMatchObject({ type: 'object', properties: { - name: { type: 'string', description: 'Full name' }, - subscribe: { type: 'boolean' } + count: { type: 'number', minimum: 1 }, + newsletter: { type: 'boolean', default: false } }, - required: ['name'] + required: ['count'] + }); + expect(validator.schemas).toEqual([]); + expect(validator.values).toEqual([]); + + await server.close(); + await clientTransport.close(); + }); + + test('Server elicitInput rejects Standard Schemas outside the elicitation subset before sending', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await clientTransport.start(); + + const initializeResponse = new Promise(resolve => { + clientTransport.onmessage = message => resolve(message); + }); + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { elicitation: { form: {} } }, + clientInfo: { name: 'test-client', version: '1.0.0' } + } }); - expect(validator.schemas).toEqual([requestedSchema]); - expect(validator.values).toEqual([{ name: 'Ada Lovelace' }]); + await initializeResponse; + + let sawElicitationRequest = false; + clientTransport.onmessage = async message => { + if ('method' in message && 'id' in message && message.method === 'elicitation/create') { + sawElicitationRequest = true; + await clientTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { action: 'decline' } + }); + } + }; + + await expect( + server.elicitInput({ + message: 'Where should we ship it?', + requestedSchema: z.object({ + address: z.object({ + city: z.string() + }) + }) + }) + ).rejects.toThrow(/flat primitive properties/); + expect(sawElicitationRequest).toBe(false); await server.close(); await clientTransport.close(); From a3817bbb49d440537a4e73efa86f0b9cb2044821 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:50:58 +0200 Subject: [PATCH 3/8] fix: preserve elicitation field titles in examples --- docs/migration.md | 4 ++-- docs/server.md | 6 +++--- examples/server/src/elicitationFormExample.ts | 8 ++++---- examples/server/src/serverGuide.examples.ts | 4 ++-- .../test/server/jsonSchemaValidatorOverride.test.ts | 9 +++++++-- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index c8d764cd0..66a2a5c2c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -696,14 +696,14 @@ server.setRequestHandler('tools/call', async (request, ctx) => { // Or pass a Standard Schema such as a Zod object for typed content. const typedElicitResult = await ctx.mcpReq.elicitInput({ message: 'Please provide details', - requestedSchema: z.object({ name: z.string() }) + requestedSchema: z.object({ name: z.string().meta({ title: 'Name' }) }) }); return { content: [{ type: 'text', text: 'done' }] }; }); ``` -Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. +Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels. These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers. diff --git a/docs/server.md b/docs/server.md index b3a2e93a4..39051e52d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -498,7 +498,7 @@ Elicitation lets a tool handler request direct input from the user — form fiel > Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. For form elicitation, pass either the restricted JSON Schema shape used by the MCP wire protocol or a Standard Schema such as a Zod object. Standard Schemas are converted to the restricted elicitation JSON Schema before being sent, so they must describe a flat object with -primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. +primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels; `.describe()` maps to JSON Schema `description`, not `title`. Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: @@ -514,8 +514,8 @@ server.registerTool( mode: 'form', message: 'Please share your feedback:', requestedSchema: z.object({ - rating: z.number().min(1).max(5).describe('Rating (1-5)'), - comment: z.string().optional().describe('Comment') + rating: z.number().min(1).max(5).meta({ title: 'Rating (1-5)' }), + comment: z.string().optional().meta({ title: 'Comment' }) }) }); if (result.action === 'accept') { diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts index 49c7babc1..013b06379 100644 --- a/examples/server/src/elicitationFormExample.ts +++ b/examples/server/src/elicitationFormExample.ts @@ -40,10 +40,10 @@ const getServer = () => { async () => { try { const registrationSchema = z.object({ - username: z.string().min(3).max(20).describe('Your desired username (3-20 characters)'), - email: z.string().email().describe('Your email address'), - password: z.string().min(8).describe('Your password (min 8 characters)'), - newsletter: z.boolean().default(false).describe('Subscribe to newsletter?') + username: z.string().min(3).max(20).meta({ title: 'Username', description: 'Your desired username (3-20 characters)' }), + email: z.string().email().meta({ title: 'Email', description: 'Your email address' }), + password: z.string().min(8).meta({ title: 'Password', description: 'Your password (min 8 characters)' }), + newsletter: z.boolean().default(false).meta({ title: 'Newsletter', description: 'Subscribe to newsletter?' }) }); // Request user information through form elicitation diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index 8aa8e80de..a286b204c 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -419,8 +419,8 @@ function registerTool_elicitation(server: McpServer) { mode: 'form', message: 'Please share your feedback:', requestedSchema: z.object({ - rating: z.number().min(1).max(5).describe('Rating (1-5)'), - comment: z.string().optional().describe('Comment') + rating: z.number().min(1).max(5).meta({ title: 'Rating (1-5)' }), + comment: z.string().optional().meta({ title: 'Comment' }) }) }); if (result.action === 'accept') { diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index e9151bb8d..16475ced0 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -130,7 +130,7 @@ describe('server JSON Schema validator overrides', () => { }; const schema = z.object({ - count: z.coerce.number().min(1), + count: z.coerce.number().min(1).meta({ title: 'Registration Count', description: 'Number of registrations to process' }), newsletter: z.boolean().default(false) }); @@ -147,7 +147,12 @@ describe('server JSON Schema validator overrides', () => { expect(requestedSchema).toMatchObject({ type: 'object', properties: { - count: { type: 'number', minimum: 1 }, + count: { + type: 'number', + minimum: 1, + title: 'Registration Count', + description: 'Number of registrations to process' + }, newsletter: { type: 'boolean', default: false } }, required: ['count'] From 1580662ec2c45365800226065cecad9ba9eb535a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 16:17:04 +0200 Subject: [PATCH 4/8] fix(server): report elicitation schema parse errors --- packages/server/src/server/server.ts | 2 +- .../test/server/jsonSchemaValidatorOverride.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index dbf96b5ae..b6c012eb2 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -622,7 +622,7 @@ export class Server extends Protocol { if (!parsedParams.success) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, - 'Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums).' + `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}` ); } return { params: parsedParams.data, standardSchema }; diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 16475ced0..703003a74 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -210,6 +210,16 @@ describe('server JSON Schema validator overrides', () => { ).rejects.toThrow(/flat primitive properties/); expect(sawElicitationRequest).toBe(false); + await expect( + server.elicitInput({ + message: 'What is your ID?', + requestedSchema: z.object({ + id: z.string().uuid() + }) + }) + ).rejects.toThrow(/format/); + expect(sawElicitationRequest).toBe(false); + await server.close(); await clientTransport.close(); }); From bb7a3774615263e33f73305a6e7af25aef4d929a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 16:51:24 +0200 Subject: [PATCH 5/8] fix(server): reject unsupported elicitation schema keywords --- packages/server/src/server/server.ts | 29 ++++++++++++++++++ .../jsonSchemaValidatorOverride.test.ts | 30 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index b6c012eb2..c34314cc2 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -86,6 +86,28 @@ export type ServerOptions = ProtocolOptions & { jsonSchemaValidator?: jsonSchemaValidator; }; +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] { + if (Array.isArray(original) && Array.isArray(parsed)) { + return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`)); + } + + if (!isJsonObject(original) || !isJsonObject(parsed)) { + return []; + } + + return Object.entries(original).flatMap(([key, value]) => { + const childPath = path ? `${path}.${key}` : key; + if (!Object.prototype.hasOwnProperty.call(parsed, key)) { + return [childPath]; + } + return findStrippedJsonSchemaPaths(value, parsed[key], childPath); + }); +} + /** * An MCP server on top of a pluggable transport. * @@ -625,6 +647,13 @@ export class Server extends Protocol { `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}` ); } + const strippedSchemaPaths = findStrippedJsonSchemaPaths(normalizedParams.requestedSchema, parsedParams.data.requestedSchema); + if (strippedSchemaPaths.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${strippedSchemaPaths.join(', ')}` + ); + } return { params: parsedParams.data, standardSchema }; } diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 703003a74..7c212a511 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -220,6 +220,36 @@ describe('server JSON Schema validator overrides', () => { ).rejects.toThrow(/format/); expect(sawElicitationRequest).toBe(false); + await expect( + server.elicitInput({ + message: 'What is your code?', + requestedSchema: z.object({ + code: z.string().regex(/^[A-Z]{3}$/) + }) + }) + ).rejects.toThrow(/properties\.code\.pattern/); + expect(sawElicitationRequest).toBe(false); + + await expect( + server.elicitInput({ + message: 'How many?', + requestedSchema: z.object({ + count: z.number().multipleOf(2) + }) + }) + ).rejects.toThrow(/properties\.count\.multipleOf/); + expect(sawElicitationRequest).toBe(false); + + await expect( + server.elicitInput({ + message: 'How many?', + requestedSchema: z.object({ + count: z.number().gt(0) + }) + }) + ).rejects.toThrow(/properties\.count\.exclusiveMinimum/); + expect(sawElicitationRequest).toBe(false); + await server.close(); await clientTransport.close(); }); From 0ad602d69cef3f2604a71e80e1739c685ccb3b8a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 17:20:31 +0200 Subject: [PATCH 6/8] fix(server): allow elicitation string formats from standard schemas --- .changeset/standard-schema-elicitation.md | 2 +- docs/migration.md | 2 +- docs/server.md | 4 +++- packages/server/src/server/server.ts | 16 ++++++++++++++++ .../server/jsonSchemaValidatorOverride.test.ts | 17 +++++++++++++---- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md index 597f104bc..384f323d6 100644 --- a/.changeset/standard-schema-elicitation.md +++ b/.changeset/standard-schema-elicitation.md @@ -4,4 +4,4 @@ --- Allow form elicitation requests to accept Standard Schema values such as Zod objects for `requestedSchema`. The server converts these schemas to MCP's restricted elicitation JSON Schema before sending and parses accepted content with the original schema before returning typed -results. +results. Zod string formats that map to MCP's supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary regex patterns remain rejected because form elicitation does not carry JSON Schema `pattern`. diff --git a/docs/migration.md b/docs/migration.md index 66a2a5c2c..13e1ca2a4 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -703,7 +703,7 @@ server.setRequestHandler('tools/call', async (request, ctx) => { }); ``` -Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels. +Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels. Zod string helpers that emit the supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary `.regex()` patterns are rejected because form elicitation does not carry JSON Schema `pattern`. These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers. diff --git a/docs/server.md b/docs/server.md index 39051e52d..231affe02 100644 --- a/docs/server.md +++ b/docs/server.md @@ -498,7 +498,9 @@ Elicitation lets a tool handler request direct input from the user — form fiel > Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. For form elicitation, pass either the restricted JSON Schema shape used by the MCP wire protocol or a Standard Schema such as a Zod object. Standard Schemas are converted to the restricted elicitation JSON Schema before being sent, so they must describe a flat object with -primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels; `.describe()` maps to JSON Schema `description`, not `title`. +primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. With Zod v4, use `.meta({ title: 'Field Label' })` for short +form-field labels; `.describe()` maps to JSON Schema `description`, not `title`. Zod string helpers that emit the supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary `.regex()` patterns are rejected because form elicitation does not carry JSON Schema +`pattern`. Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index c34314cc2..19189827d 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -90,6 +90,19 @@ function isJsonObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const ELICITATION_STRING_FORMATS = new Set(['email', 'uri', 'date', 'date-time']); + +function isSupportedFormatPattern(original: Record, parsed: Record, key: string): boolean { + return ( + key === 'pattern' && + typeof original.pattern === 'string' && + parsed.type === 'string' && + typeof parsed.format === 'string' && + original.format === parsed.format && + ELICITATION_STRING_FORMATS.has(parsed.format) + ); +} + function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] { if (Array.isArray(original) && Array.isArray(parsed)) { return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`)); @@ -102,6 +115,9 @@ function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = return Object.entries(original).flatMap(([key, value]) => { const childPath = path ? `${path}.${key}` : key; if (!Object.prototype.hasOwnProperty.call(parsed, key)) { + if (isSupportedFormatPattern(original, parsed, key)) { + return []; + } return [childPath]; } return findStrippedJsonSchemaPaths(value, parsed[key], childPath); diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 7c212a511..405585617 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -124,13 +124,14 @@ describe('server JSON Schema validator overrides', () => { await clientTransport.send({ jsonrpc: '2.0', id: message.id, - result: { action: 'accept', content: { count: '5' } } + result: { action: 'accept', content: { count: '5', email: 'user@example.com' } } }); } }; const schema = z.object({ count: z.coerce.number().min(1).meta({ title: 'Registration Count', description: 'Number of registrations to process' }), + email: z.string().email().meta({ title: 'Email', description: 'Email address' }), newsletter: z.boolean().default(false) }); @@ -142,8 +143,8 @@ describe('server JSON Schema validator overrides', () => { const result = await server.elicitInput(params); expectTypeOf(result).toMatchTypeOf>(); - expectTypeOf(result.content).toEqualTypeOf<{ count: number; newsletter: boolean } | undefined>(); - expect(result).toEqual({ action: 'accept', content: { count: 5, newsletter: false } }); + expectTypeOf(result.content).toEqualTypeOf<{ count: number; email: string; newsletter: boolean } | undefined>(); + expect(result).toEqual({ action: 'accept', content: { count: 5, email: 'user@example.com', newsletter: false } }); expect(requestedSchema).toMatchObject({ type: 'object', properties: { @@ -153,10 +154,18 @@ describe('server JSON Schema validator overrides', () => { title: 'Registration Count', description: 'Number of registrations to process' }, + email: { + type: 'string', + format: 'email', + title: 'Email', + description: 'Email address' + }, newsletter: { type: 'boolean', default: false } }, - required: ['count'] + required: ['count', 'email'] }); + const emailSchema = (requestedSchema!.properties as Record>).email!; + expect(emailSchema.pattern).toBeUndefined(); expect(validator.schemas).toEqual([]); expect(validator.values).toEqual([]); From 1308739d016c1696106828cd9d5dcfa52458210d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 23:38:17 +0200 Subject: [PATCH 7/8] fix(server): isolate elicitation schema normalization --- packages/codemod/src/generated/versions.ts | 12 +- packages/server/src/server/elicitation.ts | 106 ++++++++++++++++++ packages/server/src/server/server.ts | 85 +------------- .../jsonSchemaValidatorOverride.test.ts | 10 ++ 4 files changed, 125 insertions(+), 88 deletions(-) create mode 100644 packages/server/src/server/elicitation.ts diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a8..196a36750 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; diff --git a/packages/server/src/server/elicitation.ts b/packages/server/src/server/elicitation.ts new file mode 100644 index 000000000..896cccd7f --- /dev/null +++ b/packages/server/src/server/elicitation.ts @@ -0,0 +1,106 @@ +import type { ElicitInputFormParams, ElicitRequestFormParams, StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; +import { + ElicitRequestFormParamsSchema, + parseSchema, + ProtocolError, + ProtocolErrorCode, + standardSchemaToJsonSchema +} from '@modelcontextprotocol/core-internal'; + +export type NormalizedElicitInputFormParams = { + params: ElicitRequestFormParams; + standardSchema?: StandardSchemaWithJSON; +}; + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const ZOD_REDUNDANT_FORMAT_PATTERNS: ReadonlyMap> = new Map([ + ['email', new Set([String.raw`^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`])], + [ + 'date', + new Set([ + String.raw`^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$` + ]) + ], + [ + 'date-time', + new Set([ + String.raw`^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$` + ]) + ] +]); + +function isRedundantFormatPattern(original: Record, parsed: Record, key: string): boolean { + if ( + key !== 'pattern' || + typeof original.pattern !== 'string' || + parsed.type !== 'string' || + typeof parsed.format !== 'string' || + original.format !== parsed.format + ) { + return false; + } + + return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true; +} + +function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] { + if (Array.isArray(original) && Array.isArray(parsed)) { + return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`)); + } + + if (!isJsonObject(original) || !isJsonObject(parsed)) { + return []; + } + + return Object.entries(original).flatMap(([key, value]) => { + const childPath = path ? `${path}.${key}` : key; + if (!Object.prototype.hasOwnProperty.call(parsed, key)) { + if (isRedundantFormatPattern(original, parsed, key)) { + return []; + } + return [childPath]; + } + return findStrippedJsonSchemaPaths(value, parsed[key], childPath); + }); +} + +function isElicitInputSchema( + schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON +): schema is StandardSchemaWithJSON { + return typeof schema === 'object' && schema !== null && '~standard' in schema; +} + +export function normalizeElicitInputFormParams( + params: ElicitRequestFormParams | ElicitInputFormParams +): NormalizedElicitInputFormParams { + const formParams = + params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' as const }; + + if (isElicitInputSchema(formParams.requestedSchema)) { + const standardSchema = formParams.requestedSchema; + const normalizedParams = { + ...formParams, + requestedSchema: standardSchemaToJsonSchema(standardSchema, 'input') + }; + const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams); + if (!parsedParams.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}` + ); + } + const strippedSchemaPaths = findStrippedJsonSchemaPaths(normalizedParams.requestedSchema, parsedParams.data.requestedSchema); + if (strippedSchemaPaths.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${strippedSchemaPaths.join(', ')}` + ); + } + return { params: parsedParams.data, standardSchema }; + } + + return { params: formParams }; +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 19189827d..f3f9f87ce 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -39,7 +39,6 @@ import { CallToolResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ElicitRequestFormParamsSchema, ElicitResultSchema, EmptyResultSchema, LATEST_PROTOCOL_VERSION, @@ -52,11 +51,12 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, - standardSchemaToJsonSchema, validateStandardSchema } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import { normalizeElicitInputFormParams } from './elicitation'; + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -86,44 +86,6 @@ export type ServerOptions = ProtocolOptions & { jsonSchemaValidator?: jsonSchemaValidator; }; -function isJsonObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -const ELICITATION_STRING_FORMATS = new Set(['email', 'uri', 'date', 'date-time']); - -function isSupportedFormatPattern(original: Record, parsed: Record, key: string): boolean { - return ( - key === 'pattern' && - typeof original.pattern === 'string' && - parsed.type === 'string' && - typeof parsed.format === 'string' && - original.format === parsed.format && - ELICITATION_STRING_FORMATS.has(parsed.format) - ); -} - -function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] { - if (Array.isArray(original) && Array.isArray(parsed)) { - return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`)); - } - - if (!isJsonObject(original) || !isJsonObject(parsed)) { - return []; - } - - return Object.entries(original).flatMap(([key, value]) => { - const childPath = path ? `${path}.${key}` : key; - if (!Object.prototype.hasOwnProperty.call(parsed, key)) { - if (isSupportedFormatPattern(original, parsed, key)) { - return []; - } - return [childPath]; - } - return findStrippedJsonSchemaPaths(value, parsed[key], childPath); - }); -} - /** * An MCP server on top of a pluggable transport. * @@ -594,7 +556,7 @@ export class Server extends Protocol { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); } - const { params: formParams, standardSchema } = this.normalizeElicitInputFormParams( + const { params: formParams, standardSchema } = normalizeElicitInputFormParams( params as ElicitRequestFormParams | ElicitInputFormParams ); @@ -641,47 +603,6 @@ export class Server extends Protocol { } } - private normalizeElicitInputFormParams(params: ElicitRequestFormParams | ElicitInputFormParams): { - params: ElicitRequestFormParams; - standardSchema?: StandardSchemaWithJSON; - } { - const formParams = - params.mode === 'form' - ? (params as ElicitRequestFormParams) - : { ...(params as ElicitRequestFormParams), mode: 'form' as const }; - - if (this.isElicitInputSchema(formParams.requestedSchema)) { - const standardSchema = formParams.requestedSchema; - const normalizedParams = { - ...formParams, - requestedSchema: standardSchemaToJsonSchema(standardSchema, 'input') - }; - const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams); - if (!parsedParams.success) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}` - ); - } - const strippedSchemaPaths = findStrippedJsonSchemaPaths(normalizedParams.requestedSchema, parsedParams.data.requestedSchema); - if (strippedSchemaPaths.length > 0) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${strippedSchemaPaths.join(', ')}` - ); - } - return { params: parsedParams.data, standardSchema }; - } - - return { params: formParams }; - } - - private isElicitInputSchema( - schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON - ): schema is StandardSchemaWithJSON { - return typeof schema === 'object' && schema !== null && '~standard' in schema; - } - /** * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` * notification for the specified elicitation ID. diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 405585617..b387934f0 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -239,6 +239,16 @@ describe('server JSON Schema validator overrides', () => { ).rejects.toThrow(/properties\.code\.pattern/); expect(sawElicitationRequest).toBe(false); + await expect( + server.elicitInput({ + message: 'What is your email?', + requestedSchema: z.object({ + email: z.email({ pattern: /@corp\.com$/ }) + }) + }) + ).rejects.toThrow(/properties\.email\.pattern/); + expect(sawElicitationRequest).toBe(false); + await expect( server.elicitInput({ message: 'How many?', From a5ba576f50fb5f090e2f55c15fd9f469f1e847d4 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 26 Jun 2026 00:06:06 +0200 Subject: [PATCH 8/8] fix(server): allow zod datetime format patterns --- packages/server/src/server/elicitation.ts | 48 ++++++++++++++--- .../jsonSchemaValidatorOverride.test.ts | 53 +++++++++++++++++-- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/elicitation.ts b/packages/server/src/server/elicitation.ts index 896cccd7f..d05821a9e 100644 --- a/packages/server/src/server/elicitation.ts +++ b/packages/server/src/server/elicitation.ts @@ -16,6 +16,10 @@ function isJsonObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const ZOD_ISO_DATE_PATTERN = String.raw`(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))`; +const ZOD_ISO_TIME_PREFIX = String.raw`(?:[01]\d|2[0-3]):[0-5]\d`; +const ZOD_ISO_OFFSET_PATTERN = String.raw`([+-](?:[01]\d|2[0-3]):[0-5]\d)`; + const ZOD_REDUNDANT_FORMAT_PATTERNS: ReadonlyMap> = new Map([ ['email', new Set([String.raw`^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`])], [ @@ -23,15 +27,43 @@ const ZOD_REDUNDANT_FORMAT_PATTERNS: ReadonlyMap> = new Set([ String.raw`^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$` ]) - ], - [ - 'date-time', - new Set([ - String.raw`^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$` - ]) ] ]); +const ZOD_DATETIME_ZONE_SUFFIXES = [ + String.raw`(?:Z)`, + String.raw`(?:Z|)`, + String.raw`(?:Z|${ZOD_ISO_OFFSET_PATTERN})`, + String.raw`(?:Z||${ZOD_ISO_OFFSET_PATTERN})` +] as const; + +function escapeRegExpLiteral(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, match => `\\${match}`); +} + +const ZOD_PRECISION_TIME_PATTERN = new RegExp(String.raw`^${escapeRegExpLiteral(String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d\.\d{`)}\d+\}$`); + +function isZodIsoDatetimePattern(pattern: string): boolean { + const prefix = `^${ZOD_ISO_DATE_PATTERN}T(?:`; + if (!pattern.startsWith(prefix) || !pattern.endsWith(')$')) { + return false; + } + + const innerPattern = pattern.slice(prefix.length, -2); + const zoneSuffix = ZOD_DATETIME_ZONE_SUFFIXES.find(suffix => innerPattern.endsWith(suffix)); + if (!zoneSuffix) { + return false; + } + + const timePattern = innerPattern.slice(0, -zoneSuffix.length); + return ( + timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}` || + timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d` || + timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}(?::[0-5]\d(?:\.\d+)?)?` || + ZOD_PRECISION_TIME_PATTERN.test(timePattern) + ); +} + function isRedundantFormatPattern(original: Record, parsed: Record, key: string): boolean { if ( key !== 'pattern' || @@ -43,6 +75,10 @@ function isRedundantFormatPattern(original: Record, parsed: Rec return false; } + if (parsed.format === 'date-time') { + return isZodIsoDatetimePattern(original.pattern); + } + return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true; } diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index b387934f0..d6ec44501 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -6,6 +6,7 @@ import type { ElicitInputResult, JsonSchemaType, JsonSchemaValidatorResult, + StandardSchemaWithJSON, jsonSchemaValidator } from '../../src/index'; import { fromJsonSchema } from '../../src/fromJsonSchema'; @@ -124,7 +125,10 @@ describe('server JSON Schema validator overrides', () => { await clientTransport.send({ jsonrpc: '2.0', id: message.id, - result: { action: 'accept', content: { count: '5', email: 'user@example.com' } } + result: { + action: 'accept', + content: { count: '5', email: 'user@example.com', startsAt: '2026-01-02T03:04:05+01:00' } + } }); } }; @@ -132,6 +136,7 @@ describe('server JSON Schema validator overrides', () => { const schema = z.object({ count: z.coerce.number().min(1).meta({ title: 'Registration Count', description: 'Number of registrations to process' }), email: z.string().email().meta({ title: 'Email', description: 'Email address' }), + startsAt: z.iso.datetime({ offset: true }).meta({ title: 'Start Time' }), newsletter: z.boolean().default(false) }); @@ -143,8 +148,11 @@ describe('server JSON Schema validator overrides', () => { const result = await server.elicitInput(params); expectTypeOf(result).toMatchTypeOf>(); - expectTypeOf(result.content).toEqualTypeOf<{ count: number; email: string; newsletter: boolean } | undefined>(); - expect(result).toEqual({ action: 'accept', content: { count: 5, email: 'user@example.com', newsletter: false } }); + expectTypeOf(result.content).toEqualTypeOf<{ count: number; email: string; startsAt: string; newsletter: boolean } | undefined>(); + expect(result).toEqual({ + action: 'accept', + content: { count: 5, email: 'user@example.com', startsAt: '2026-01-02T03:04:05+01:00', newsletter: false } + }); expect(requestedSchema).toMatchObject({ type: 'object', properties: { @@ -160,12 +168,19 @@ describe('server JSON Schema validator overrides', () => { title: 'Email', description: 'Email address' }, + startsAt: { + type: 'string', + format: 'date-time', + title: 'Start Time' + }, newsletter: { type: 'boolean', default: false } }, - required: ['count', 'email'] + required: ['count', 'email', 'startsAt'] }); const emailSchema = (requestedSchema!.properties as Record>).email!; expect(emailSchema.pattern).toBeUndefined(); + const startsAtSchema = (requestedSchema!.properties as Record>).startsAt!; + expect(startsAtSchema.pattern).toBeUndefined(); expect(validator.schemas).toEqual([]); expect(validator.values).toEqual([]); @@ -249,6 +264,36 @@ describe('server JSON Schema validator overrides', () => { ).rejects.toThrow(/properties\.email\.pattern/); expect(sawElicitationRequest).toBe(false); + const customDateTimePatternSchema = { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => ({ + type: 'object', + properties: { + startsAt: { + type: 'string', + format: 'date-time', + pattern: '2026' + } + }, + required: ['startsAt'] + }), + output: () => ({}) + } + } + } satisfies StandardSchemaWithJSON; + + await expect( + server.elicitInput({ + message: 'When should we start?', + requestedSchema: customDateTimePatternSchema + }) + ).rejects.toThrow(/properties\.startsAt\.pattern/); + expect(sawElicitationRequest).toBe(false); + await expect( server.elicitInput({ message: 'How many?',