From 193f132bd7db8d461916c5cf7da25471d4ac42be Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 14:45:33 +0200 Subject: [PATCH 01/14] feat(server): accept standard schemas for elicitation --- packages/core-internal/src/shared/protocol.ts | 22 +++++- packages/server/src/server/server.ts | 48 +++++++++++-- .../jsonSchemaValidatorOverride.test.ts | 70 +++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index ffab6e8df8..25e78b41f4 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -45,7 +45,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 { bootstrapOutboundCodec } from '../wire/bootstrap'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec'; @@ -444,6 +444,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. */ @@ -467,7 +479,13 @@ export type ServerContext = BaseContext & { * replaced by input_required results in the 2026-07-28 protocol. If your factory serves * both eras, this only works on the legacy path. */ - 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 4151849494..381e0ffcf6 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -10,6 +10,8 @@ import type { CreateMessageResult, CreateMessageResultWithTools, DiscoverResult, + ElicitInputFormParams, + ElicitInputResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, @@ -34,6 +36,7 @@ import type { Result, ServerCapabilities, ServerContext, + StandardSchemaWithJSON, ToolResultContent, ToolUseContent } from '@modelcontextprotocol/core-internal'; @@ -58,6 +61,7 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + standardSchemaToJsonSchema, withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -1127,7 +1131,15 @@ export class Server extends Protocol { * results in the 2026-07-28 protocol. If your factory serves both eras, this only works on the * legacy path. */ - 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> { this._assertPushApiInServedEra('elicitation/create'); const mode = (params.mode ?? 'form') as 'form' | 'url'; @@ -1158,7 +1170,7 @@ export class Server extends Protocol { * `acceptedContent` overload and can re-ask). */ private async _sendElicitationLeg( - params: ElicitRequestFormParams | ElicitRequestURLParams, + params: ElicitRequestFormParams | ElicitRequestURLParams | ElicitInputFormParams, options?: RequestOptions, behavior?: { validateAcceptedContent: boolean } ): Promise { @@ -1173,8 +1185,9 @@ export class Server extends Protocol { return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { - 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.request({ method: 'elicitation/create', params: formParams }, options); @@ -1204,6 +1217,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 e5edf18398..993eed7477 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 d03d0877fc6b2a764b7b15c5e2cb08a239a00a93 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:27:56 +0200 Subject: [PATCH 02/14] fix: validate standard schema elicitation --- .changeset/standard-schema-elicitation.md | 7 ++ .../core-internal/src/exports/public/index.ts | 2 + packages/server/src/server/server.ts | 68 ++++++++++----- .../jsonSchemaValidatorOverride.test.ts | 87 ++++++++++++++++--- 4 files changed, 126 insertions(+), 38 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 0000000000..597f104bcb --- /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/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 0a50e00dd5..b4ed761bbf 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -46,6 +46,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 381e0ffcf6..b922cad7bf 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -51,6 +51,7 @@ import { legacyProtocolVersions, LOG_LEVEL_META_KEY, LoggingLevelSchema, + ElicitRequestFormParamsSchema, mergeCapabilities, missingClientCapabilities, MissingRequiredClientCapabilityError, @@ -62,6 +63,7 @@ import { SdkError, SdkErrorCode, standardSchemaToJsonSchema, + validateStandardSchema, withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -1185,31 +1187,42 @@ export class Server extends Protocol { return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { - const formParams = this.normalizeElicitInputFormParams( + const { params: formParams, standardSchema } = this.normalizeElicitInputFormParams( params as ElicitRequestFormParams | ElicitInputFormParams ); const result = await this.request({ method: 'elicitation/create', params: formParams }, options); - if (validateAcceptedContent && 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 (validateAcceptedContent && 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; @@ -1217,25 +1230,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 993eed7477..e9151bb8d1 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 bf50ef36118896074a8e4153031bdac634a2ff6a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:50:58 +0200 Subject: [PATCH 03/14] fix: preserve elicitation field titles in examples --- .../test/server/jsonSchemaValidatorOverride.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index e9151bb8d1..16475ced00 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 5d041f5b0d129333e0798e5156187802c6d58d12 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 16:17:04 +0200 Subject: [PATCH 04/14] 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 b922cad7bf..25bb4613c0 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1249,7 +1249,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 16475ced00..703003a74f 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 c4e2bcc3afb1fac21d02296400139108c7fa712e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 16:51:24 +0200 Subject: [PATCH 05/14] 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 25bb4613c0..9848adce41 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -244,6 +244,28 @@ export function installModernOnlyHandlers(server: Server, servedModernVersions: installDiscoverHandler(server, servedModernVersions); } +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. * @@ -1252,6 +1274,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 703003a74f..7c212a5116 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 b0e51ab9ad466b556b3f0bf00481e77e6fd31678 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 17:20:31 +0200 Subject: [PATCH 06/14] fix(server): allow elicitation string formats from standard schemas --- .changeset/standard-schema-elicitation.md | 2 +- packages/server/src/server/server.ts | 16 ++++++++++++++++ .../server/jsonSchemaValidatorOverride.test.ts | 17 +++++++++++++---- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md index 597f104bcb..384f323d6c 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/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 9848adce41..ed504535d5 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -248,6 +248,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}]`)); @@ -260,6 +273,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 7c212a5116..4055856175 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 8dad4115b538a78fd58cf5141f1dcffc03d03b4b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 23:38:17 +0200 Subject: [PATCH 07/14] fix(server): isolate elicitation schema normalization --- packages/server/src/server/elicitation.ts | 106 ++++++++++++++++++ packages/server/src/server/server.ts | 84 +------------- .../jsonSchemaValidatorOverride.test.ts | 10 ++ 3 files changed, 118 insertions(+), 82 deletions(-) create mode 100644 packages/server/src/server/elicitation.ts diff --git a/packages/server/src/server/elicitation.ts b/packages/server/src/server/elicitation.ts new file mode 100644 index 0000000000..896cccd7f9 --- /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 ed504535d5..59eb482492 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -51,7 +51,6 @@ import { legacyProtocolVersions, LOG_LEVEL_META_KEY, LoggingLevelSchema, - ElicitRequestFormParamsSchema, mergeCapabilities, missingClientCapabilities, MissingRequiredClientCapabilityError, @@ -62,12 +61,12 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, - standardSchemaToJsonSchema, validateStandardSchema, withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import { normalizeElicitInputFormParams } from './elicitation'; import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim'; /** @@ -244,44 +243,6 @@ export function installModernOnlyHandlers(server: Server, servedModernVersions: installDiscoverHandler(server, servedModernVersions); } -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. * @@ -1225,7 +1186,7 @@ export class Server extends Protocol { return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { - const { params: formParams, standardSchema } = this.normalizeElicitInputFormParams( + const { params: formParams, standardSchema } = normalizeElicitInputFormParams( params as ElicitRequestFormParams | ElicitInputFormParams ); @@ -1268,47 +1229,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 4055856175..b387934f0f 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 481b35d2d242435df762aa20690d653211ec8c16 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 26 Jun 2026 00:06:06 +0200 Subject: [PATCH 08/14] 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 896cccd7f9..d05821a9e9 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 b387934f0f..d6ec445014 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?', From 8b0a9b6c6207b8380110f9e24ca29b580b425704 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 26 Jun 2026 11:38:27 +0200 Subject: [PATCH 09/14] fix(server): update elicitation changeset packages --- .changeset/standard-schema-elicitation.md | 3 ++- .../server/test/server/jsonSchemaValidatorOverride.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md index 384f323d6c..ee0122362e 100644 --- a/.changeset/standard-schema-elicitation.md +++ b/.changeset/standard-schema-elicitation.md @@ -1,6 +1,7 @@ --- -'@modelcontextprotocol/core': minor +'@modelcontextprotocol/core-internal': minor '@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': 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 diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index d6ec445014..6f4e2ce4bb 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -135,7 +135,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' }), + email: z.email().meta({ title: 'Email', description: 'Email address' }), startsAt: z.iso.datetime({ offset: true }).meta({ title: 'Start Time' }), newsletter: z.boolean().default(false) }); From 15aefd2b1e8da4c17075ca6799e4661b57e4d010 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 09:40:41 +0100 Subject: [PATCH 10/14] fix(server): type standard schema elicitation leg --- packages/server/src/server/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 59eb482492..d4b05f2d0d 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -405,7 +405,7 @@ export class Server extends Protocol { // session-wide stream to deliver it on. return ctx.mcpReq.notify({ method: 'notifications/message', params: { 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 @@ -1174,7 +1174,7 @@ export class Server extends Protocol { params: ElicitRequestFormParams | ElicitRequestURLParams | ElicitInputFormParams, options?: RequestOptions, behavior?: { validateAcceptedContent: boolean } - ): Promise { + ): Promise> { const mode = (params.mode ?? 'form') as 'form' | 'url'; const validateAcceptedContent = behavior?.validateAcceptedContent ?? true; From 9b163b0e36f9c20d45f7de52e19ef3727fa75904 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 10:58:49 +0100 Subject: [PATCH 11/14] fix(server): normalize input-required elicitation schemas --- packages/core-internal/src/index.ts | 1 + .../src/shared}/elicitation.ts | 30 ++++++++++----- .../core-internal/src/shared/inputRequired.ts | 18 +++++++-- .../test/shared/inputRequired.test.ts | 37 +++++++++++++++++++ packages/server/src/server/server.ts | 2 +- 5 files changed, 75 insertions(+), 13 deletions(-) rename packages/{server/src/server => core-internal/src/shared}/elicitation.ts (84%) diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 7ea56117c7..7eede8aa5f 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -4,6 +4,7 @@ export * from './errors/sdkErrors'; export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; +export * from './shared/elicitation'; export * from './shared/envelope'; export * from './shared/inboundClassification'; export * from './shared/inputRequired'; diff --git a/packages/server/src/server/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts similarity index 84% rename from packages/server/src/server/elicitation.ts rename to packages/core-internal/src/shared/elicitation.ts index d05821a9e9..886e1df736 100644 --- a/packages/server/src/server/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -1,11 +1,11 @@ -import type { ElicitInputFormParams, ElicitRequestFormParams, StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; -import { - ElicitRequestFormParamsSchema, - parseSchema, - ProtocolError, - ProtocolErrorCode, - standardSchemaToJsonSchema -} from '@modelcontextprotocol/core-internal'; +import { ProtocolErrorCode } from '../types/enums'; +import { ProtocolError } from '../types/errors'; +import { ElicitRequestFormParamsSchema } from '../types/schemas'; +import type { ElicitRequestFormParams } from '../types/types'; +import { parseSchema } from '../util/schema'; +import type { StandardSchemaWithJSON } from '../util/standardSchema'; +import { standardSchemaToJsonSchema } from '../util/standardSchema'; +import type { ElicitInputFormParams } from './protocol'; export type NormalizedElicitInputFormParams = { params: ElicitRequestFormParams; @@ -109,6 +109,18 @@ function isElicitInputSchema( return typeof schema === 'object' && schema !== null && '~standard' in schema; } +function convertStandardElicitationSchema(standardSchema: StandardSchemaWithJSON): Record { + try { + return standardSchemaToJsonSchema(standardSchema, 'input'); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema must describe an object with flat primitive properties: ${detail}` + ); + } +} + export function normalizeElicitInputFormParams( params: ElicitRequestFormParams | ElicitInputFormParams ): NormalizedElicitInputFormParams { @@ -119,7 +131,7 @@ export function normalizeElicitInputFormParams( const standardSchema = formParams.requestedSchema; const normalizedParams = { ...formParams, - requestedSchema: standardSchemaToJsonSchema(standardSchema, 'input') + requestedSchema: convertStandardElicitationSchema(standardSchema) }; const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams); if (!parsedParams.success) { diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 0c7ba043e1..09f8255985 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -30,7 +30,9 @@ import type { InputResponses, Root } from '../types/types'; -import type { StandardSchemaV1 } from '../util/standardSchema'; +import type { StandardSchemaV1, StandardSchemaWithJSON } from '../util/standardSchema'; +import { normalizeElicitInputFormParams } from './elicitation'; +import type { ElicitInputFormParams } from './protocol'; /** The shape accepted by {@linkcode inputRequired}. */ export interface InputRequiredSpec { @@ -57,6 +59,9 @@ interface InputRequiredBuilder { */ (spec: InputRequiredSpec): InputRequiredResult; + /** Builds an embedded form-mode elicitation request (`elicitation/create`). */ + elicit(params: Omit, 'mode'> & { mode?: 'form' }): InputRequest; + /** Builds an embedded form-mode elicitation request (`elicitation/create`). */ elicit(params: Omit & { mode?: 'form' }): InputRequest; @@ -118,8 +123,15 @@ function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult { * ``` */ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequired, { - elicit(params: Omit & { mode?: 'form' }): InputRequest { - return { method: 'elicitation/create', params: { ...params, mode: 'form' } }; + elicit( + params: + | (Omit & { mode?: 'form' }) + | (Omit, 'mode'> & { mode?: 'form' }) + ): InputRequest { + const { params: normalizedParams } = normalizeElicitInputFormParams( + params as ElicitRequestFormParams | ElicitInputFormParams + ); + return { method: 'elicitation/create', params: normalizedParams }; }, elicitUrl(params: Omit): InputRequest { // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index 2bce27fcf1..aba3360df7 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -8,6 +8,7 @@ import { describe, expect, test } from 'vitest'; import * as z from 'zod/v4'; import { acceptedContent, inputRequired, withInputRequired } from '../../src/shared/inputRequired'; +import { ProtocolError } from '../../src/types/errors'; import { isInputRequiredResult } from '../../src/types/guards'; import { validateStandardSchema } from '../../src/util/standardSchema'; @@ -51,6 +52,42 @@ describe('inputRequired() builder', () => { }); expect(inputRequired.listRoots()).toEqual({ method: 'roots/list' }); }); + + test('elicit converts Standard Schema requestedSchema to elicitation JSON Schema', () => { + const request = inputRequired.elicit({ + message: 'Email?', + requestedSchema: z.object({ email: z.email() }) + }); + + expect(request).toEqual({ + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Email?', + requestedSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { email: { type: 'string', format: 'email' } }, + required: ['email'] + } + } + }); + }); + + test('elicit rejects non-object Standard Schema roots as invalid elicitation params', () => { + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.string() + }) + ).toThrow(ProtocolError); + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.string() + }) + ).toThrow(/Elicitation requestedSchema must describe an object/); + }); }); describe('acceptedContent()', () => { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index d4b05f2d0d..42699a2b69 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -55,6 +55,7 @@ import { missingClientCapabilities, MissingRequiredClientCapabilityError, modernProtocolVersions, + normalizeElicitInputFormParams, parseSchema, Protocol, ProtocolError, @@ -66,7 +67,6 @@ import { } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import { normalizeElicitInputFormParams } from './elicitation'; import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim'; /** From 12eb9e5daa735861a7b08bd481299865c7308438 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 11:36:23 +0100 Subject: [PATCH 12/14] fix(server): tolerate elicitation annotation metadata --- .../core-internal/src/shared/elicitation.ts | 8 ++++- .../test/shared/inputRequired.test.ts | 29 +++++++++++++++++++ packages/server/src/server/server.ts | 12 ++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index 886e1df736..8550759f85 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -82,6 +82,12 @@ function isRedundantFormatPattern(original: Record, parsed: Rec return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true; } +const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', 'writeOnly']); + +function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean { + return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-'); +} + 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}]`)); @@ -94,7 +100,7 @@ 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 (isRedundantFormatPattern(original, parsed, key)) { + if (isRedundantFormatPattern(original, parsed, key) || isAnnotationOnlyJsonSchemaKeyword(key)) { return []; } return [childPath]; diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index aba3360df7..cbc08f93ea 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -74,6 +74,35 @@ describe('inputRequired() builder', () => { }); }); + test('elicit drops annotation-only metadata from Standard Schema properties', () => { + const request = inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.object({ + name: z.string().meta({ + title: 'Name', + examples: ['Ada Lovelace'], + deprecated: false, + readOnly: true, + 'x-ui-order': 1 + }) + }) + }); + + expect(request).toEqual({ + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Name?', + requestedSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string', title: 'Name' } }, + required: ['name'] + } + } + }); + }); + test('elicit rejects non-object Standard Schema roots as invalid elicitation params', () => { expect(() => inputRequired.elicit({ diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 42699a2b69..906048abc7 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1136,6 +1136,18 @@ export class Server extends Protocol { params: ElicitInputFormParams, options?: RequestOptions ): Promise>; + /** + * Creates an elicitation request for the given parameters. + * For backwards compatibility, `mode` may be omitted for form requests and will default to `"form"`. + * @param params The parameters for the elicitation request. + * @param options Optional request options. + * @returns The result of the elicitation request. + * + * @deprecated Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) + * instead. The 2025 push-style server-to-client request model is replaced by input_required + * results in the 2026-07-28 protocol. If your factory serves both eras, this only works on the + * legacy path. + */ async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; async elicitInput( params: ElicitRequestFormParams | ElicitRequestURLParams | ElicitInputFormParams, From b2d162d4b5de3ca003d53e8c2057621b32106369 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 20:59:51 +0000 Subject: [PATCH 13/14] fix: harden Standard Schema elicitation conversion - Reject unknown root-level requestedSchema keywords after conversion. The wire schema's root is a catchall, so keys like the additionalProperties emitted by z.strictObject() passed the stripped-keys gate onto the wire; the root is now held to the spec-declared shape (type/properties/required/$schema, derived from the wire schema), with annotation-only root keywords dropped. - Derive redundant format-pattern references from the installed zod at runtime instead of vendoring its regexes. The vendored literals were byte-coupled to one zod version against a ^4.2.0 peer range, so any in-range regex change would reject working schemas at user runtime while pinned CI stayed green. Customized patterns still reject. - Throw TypeError (with cause) from inputRequired.elicit on unsupported schemas, matching the builder's authoring-error convention; ProtocolError from a prompts/get handler would reach the client as InvalidParams on its own request. --- .changeset/standard-schema-elicitation.md | 2 +- .../core-internal/src/shared/elicitation.ts | 134 +++++++++++------ .../core-internal/src/shared/inputRequired.ts | 16 ++- .../test/shared/elicitation.test.ts | 136 ++++++++++++++++++ .../test/shared/inputRequired.test.ts | 20 ++- .../jsonSchemaValidatorOverride.test.ts | 10 ++ 6 files changed, 264 insertions(+), 54 deletions(-) create mode 100644 packages/core-internal/test/shared/elicitation.test.ts diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md index ee0122362e..d746c314de 100644 --- a/.changeset/standard-schema-elicitation.md +++ b/.changeset/standard-schema-elicitation.md @@ -5,4 +5,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. 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`. +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`. The converted schema's root is held to the spec's shape (`type`, `properties`, `required`, `$schema`): unknown root keywords — such as the `additionalProperties` emitted by `z.strictObject()` — are rejected before sending, and annotation-only root keywords (`title`, `description`) are dropped. diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index 8550759f85..8e6407e6d4 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -1,3 +1,5 @@ +import * as z from 'zod/v4'; + import { ProtocolErrorCode } from '../types/enums'; import { ProtocolError } from '../types/errors'; import { ElicitRequestFormParamsSchema } from '../types/schemas'; @@ -16,52 +18,66 @@ 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,}$`])], - [ - '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])))$` - ]) - ] -]); - -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}`); +// A `pattern` emitted beside a supported `format` is redundant — zod realizes every format +// check as a regex — and is dropped so the wire schema stays within the elicitation subset. +// The reference patterns are derived from the installed zod at runtime rather than vendored +// as string literals: zod's format regexes change across in-range releases, so a vendored +// copy would start rejecting schemas produced by any newer zod while CI (lockfile-pinned) +// stays green. A pattern the installed zod would not emit for that format — e.g. a +// customized `z.email({ pattern })` — still rejects, because the wire schema cannot carry it. +// Residual limitation: if the app resolves a second zod copy whose regexes differ from this +// package's resolved zod, its emissions won't match the reference and reject (fail closed); +// zod is a peer dependency precisely so installs dedupe to one copy. +// Derivation is cheap (a handful of toJSONSchema calls) and only runs when a stripped +// `pattern` sits beside a matching `format`, so no caching. + +function zodEmittedPattern(schema: z.ZodType): string | undefined { + // Conversion options must stay in lockstep with standardSchemaToJsonSchema (which + // produced the pattern under comparison via `~standard.jsonSchema.input`). + const jsonSchema = z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' }) as Record; + return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined; } -const ZOD_PRECISION_TIME_PATTERN = new RegExp(String.raw`^${escapeRegExpLiteral(String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d\.\d{`)}\d+\}$`); +const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/; -function isZodIsoDatetimePattern(pattern: string): boolean { - const prefix = `^${ZOD_ISO_DATE_PATTERN}T(?:`; - if (!pattern.startsWith(prefix) || !pattern.endsWith(')$')) { - return false; +function datetimeReferenceSchemas(pattern: string): z.ZodType[] { + // The emitted pattern depends on the authoring options (offset/local/precision); the + // fraction-digit count recovered from the pattern under test keeps the candidate set + // finite. Duplicate candidates are fine — the result feeds a Set. + const fractionDigits = DATETIME_FRACTION_DIGITS.exec(pattern); + const precisions: Array = [undefined, -1, 0]; + if (fractionDigits) { + precisions.push(Number(fractionDigits[1])); } + return [false, true].flatMap(local => + [false, true].flatMap(offset => precisions.map(precision => z.iso.datetime({ local, offset, precision }))) + ); +} - const innerPattern = pattern.slice(prefix.length, -2); - const zoneSuffix = ZOD_DATETIME_ZONE_SUFFIXES.find(suffix => innerPattern.endsWith(suffix)); - if (!zoneSuffix) { - return false; +function referencePatternsForFormat(format: string, pattern: string): ReadonlySet { + let referenceSchemas: z.ZodType[]; + switch (format) { + case 'email': { + referenceSchemas = [z.email()]; + break; + } + case 'uri': { + referenceSchemas = [z.url()]; + break; + } + case 'date': { + referenceSchemas = [z.iso.date()]; + break; + } + case 'date-time': { + referenceSchemas = datetimeReferenceSchemas(pattern); + break; + } + default: { + referenceSchemas = []; + } } - - 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) - ); + return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined)); } function isRedundantFormatPattern(original: Record, parsed: Record, key: string): boolean { @@ -75,11 +91,7 @@ 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; + return referencePatternsForFormat(parsed.format, original.pattern).has(original.pattern); } const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', 'writeOnly']); @@ -88,6 +100,36 @@ function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean { return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-'); } +// The spec declares a closed shape for the `requestedSchema` root: `$schema`, `type`, +// `properties` and `required` only. The wire schema cannot enforce that (its root is a +// catchall so hand-authored extensions stay wire-legal), and the stripped-keys diff below +// never fires for root keys the catchall retains — so converted Standard Schemas are pruned +// here: annotation-only root keywords are dropped, anything else unknown rejects (e.g. +// `z.strictObject()` emits a root `additionalProperties: false`). The keyword set is derived +// from the wire schema so it tracks spec revisions; `$schema` is spec-declared but absent +// from the wire schema's declared keys (the catchall admits it). +const ELICITATION_ROOT_KEYWORDS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]); +const ROOT_ANNOTATION_KEYWORDS = new Set(['title', 'description']); + +function pruneElicitationSchemaRoot(schema: Record): Record { + const requestedSchema: Record = {}; + const unsupportedKeys: string[] = []; + for (const [key, value] of Object.entries(schema)) { + if (ELICITATION_ROOT_KEYWORDS.has(key)) { + requestedSchema[key] = value; + } else if (!ROOT_ANNOTATION_KEYWORDS.has(key) && !isAnnotationOnlyJsonSchemaKeyword(key)) { + unsupportedKeys.push(key); + } + } + if (unsupportedKeys.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${unsupportedKeys.join(', ')}` + ); + } + return requestedSchema; +} + 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}]`)); @@ -137,7 +179,7 @@ export function normalizeElicitInputFormParams( const standardSchema = formParams.requestedSchema; const normalizedParams = { ...formParams, - requestedSchema: convertStandardElicitationSchema(standardSchema) + requestedSchema: pruneElicitationSchemaRoot(convertStandardElicitationSchema(standardSchema)) }; const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams); if (!parsedParams.success) { diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 09f8255985..6ae6a3bd02 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -17,6 +17,7 @@ * discriminator, and hand-built result literals are equally legal — the * server seam re-checks the at-least-one rule for them. */ +import { ProtocolError } from '../types/errors'; import { isInputRequiredResult } from '../types/guards'; import type { CreateMessageRequestParams, @@ -128,10 +129,17 @@ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequi | (Omit & { mode?: 'form' }) | (Omit, 'mode'> & { mode?: 'form' }) ): InputRequest { - const { params: normalizedParams } = normalizeElicitInputFormParams( - params as ElicitRequestFormParams | ElicitInputFormParams - ); - return { method: 'elicitation/create', params: normalizedParams }; + try { + const { params: normalizedParams } = normalizeElicitInputFormParams( + params as ElicitRequestFormParams | ElicitInputFormParams + ); + return { method: 'elicitation/create', params: normalizedParams }; + } catch (error) { + // Authoring mistakes in the builder surface as TypeError, matching inputRequired() + // itself; a ProtocolError escaping a prompts/get or resources/read handler would + // reach the client as InvalidParams on its own request. + throw error instanceof ProtocolError ? new TypeError(error.message, { cause: error }) : error; + } }, elicitUrl(params: Omit): InputRequest { // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the diff --git a/packages/core-internal/test/shared/elicitation.test.ts b/packages/core-internal/test/shared/elicitation.test.ts new file mode 100644 index 0000000000..471f75994a --- /dev/null +++ b/packages/core-internal/test/shared/elicitation.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { normalizeElicitInputFormParams } from '../../src/shared/elicitation'; +import { ProtocolError } from '../../src/types/errors'; +import type { StandardSchemaWithJSON } from '../../src/util/standardSchema'; + +function schemaFromJson(jsonSchema: Record): StandardSchemaWithJSON { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + output: () => ({}) + } + } + } satisfies StandardSchemaWithJSON; +} + +describe('normalizeElicitInputFormParams root keywords', () => { + test('keeps the spec-declared root keys including $schema', () => { + const { params } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: z.object({ name: z.string() }) + }); + + expect(params.requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + }); + + test('drops annotation-only root keywords', () => { + const { params } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: z.object({ name: z.string() }).meta({ title: 'User', description: 'User info', 'x-ui-hint': 'compact' }) + }); + + expect(params.requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + }); + + test.each([ + ['z.strictObject emits additionalProperties: false', z.strictObject({ name: z.string() }), /additionalProperties/], + ['z.looseObject emits additionalProperties: {}', z.looseObject({ name: z.string() }), /additionalProperties/], + [ + 'catchall emits a nested schema under additionalProperties', + z.object({ name: z.string() }).catchall(z.object({ x: z.string() })), + /additionalProperties/ + ], + ['a root default cannot ride the wire', z.object({ name: z.string() }).default({ name: 'x' }), /default/], + [ + 'a custom schema emitting $defs', + schemaFromJson({ + $defs: { Name: { type: 'string' } }, + type: 'object', + properties: { name: { $ref: '#/$defs/Name' } }, + required: ['name'] + }), + /\$defs/ + ] + ])('rejects unsupported root keywords: %s', (_label, requestedSchema, message) => { + const params = { message: 'Name?', requestedSchema } as Parameters[0]; + const act = () => normalizeElicitInputFormParams(params); + expect(act).toThrow(ProtocolError); + expect(act).toThrow(/unsupported JSON Schema keyword\(s\)/); + expect(act).toThrow(message); + }); +}); + +describe('normalizeElicitInputFormParams redundant format patterns', () => { + test.each([ + ['z.email()', z.email(), 'email'], + ['z.url()', z.url(), 'uri'], + ['z.iso.date()', z.iso.date(), 'date'], + ['z.iso.datetime()', z.iso.datetime(), 'date-time'], + ['z.iso.datetime({ offset: true })', z.iso.datetime({ offset: true }), 'date-time'], + ['z.iso.datetime({ local: true })', z.iso.datetime({ local: true }), 'date-time'], + ['z.iso.datetime({ precision: -1 })', z.iso.datetime({ precision: -1 }), 'date-time'], + ['z.iso.datetime({ precision: 0 })', z.iso.datetime({ precision: 0 }), 'date-time'], + ['z.iso.datetime({ precision: 3, offset: true })', z.iso.datetime({ precision: 3, offset: true }), 'date-time'] + ])('accepts %s and strips the zod-emitted pattern', (_label, fieldSchema, format) => { + const { params } = normalizeElicitInputFormParams({ + message: 'Value?', + requestedSchema: z.object({ value: fieldSchema }) + }); + + const valueSchema = (params.requestedSchema.properties as Record>).value!; + expect(valueSchema.format).toBe(format); + expect(valueSchema.pattern).toBeUndefined(); + }); + + test('accepts exactly the pattern the installed zod emits for a format', () => { + const emittedPattern = (z.toJSONSchema(z.email(), { target: 'draft-2020-12', io: 'input' }) as Record) + .pattern as string; + const { params } = normalizeElicitInputFormParams({ + message: 'Email?', + requestedSchema: schemaFromJson({ + type: 'object', + properties: { email: { type: 'string', format: 'email', pattern: emittedPattern } }, + required: ['email'] + }) + }); + + const emailSchema = (params.requestedSchema.properties as Record>).email!; + expect(emailSchema.format).toBe('email'); + expect(emailSchema.pattern).toBeUndefined(); + }); + + test.each([ + ['a customized email pattern', z.object({ email: z.email({ pattern: /@corp\.com$/ }) }), /properties\.email\.pattern/], + [ + 'a pattern the installed zod would not emit for the format', + schemaFromJson({ + type: 'object', + properties: { email: { type: 'string', format: 'email', pattern: '^different$' } }, + required: ['email'] + }), + /properties\.email\.pattern/ + ], + ['a pattern without a supported format', z.object({ code: z.string().regex(/^[A-Z]{3}$/) }), /properties\.code\.pattern/] + ])('rejects %s', (_label, requestedSchema, message) => { + const params = { message: 'Value?', requestedSchema } as Parameters[0]; + const act = () => normalizeElicitInputFormParams(params); + expect(act).toThrow(ProtocolError); + expect(act).toThrow(message); + }); +}); diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index cbc08f93ea..e183b4bd83 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -8,7 +8,6 @@ import { describe, expect, test } from 'vitest'; import * as z from 'zod/v4'; import { acceptedContent, inputRequired, withInputRequired } from '../../src/shared/inputRequired'; -import { ProtocolError } from '../../src/types/errors'; import { isInputRequiredResult } from '../../src/types/guards'; import { validateStandardSchema } from '../../src/util/standardSchema'; @@ -103,13 +102,13 @@ describe('inputRequired() builder', () => { }); }); - test('elicit rejects non-object Standard Schema roots as invalid elicitation params', () => { + test('elicit rejects non-object Standard Schema roots as a TypeError', () => { expect(() => inputRequired.elicit({ message: 'Name?', requestedSchema: z.string() }) - ).toThrow(ProtocolError); + ).toThrow(TypeError); expect(() => inputRequired.elicit({ message: 'Name?', @@ -117,6 +116,21 @@ describe('inputRequired() builder', () => { }) ).toThrow(/Elicitation requestedSchema must describe an object/); }); + + test('elicit rejects schemas outside the elicitation subset as a TypeError', () => { + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.strictObject({ name: z.string() }) + }) + ).toThrow(TypeError); + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.strictObject({ name: z.string() }) + }) + ).toThrow(/unsupported JSON Schema keyword\(s\).*additionalProperties/); + }); }); describe('acceptedContent()', () => { diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 6f4e2ce4bb..b9bb4ad674 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -304,6 +304,16 @@ describe('server JSON Schema validator overrides', () => { ).rejects.toThrow(/properties\.count\.multipleOf/); expect(sawElicitationRequest).toBe(false); + await expect( + server.elicitInput({ + message: 'What is your name?', + requestedSchema: z.strictObject({ + name: z.string() + }) + }) + ).rejects.toThrow(/unsupported JSON Schema keyword\(s\).*additionalProperties/); + expect(sawElicitationRequest).toBe(false); + await expect( server.elicitInput({ message: 'How many?', From 85d736534095ae50b7150890f936f58fae10f217 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 21:11:11 +0000 Subject: [PATCH 14/14] fix: route function-typed Standard Schemas through elicitation conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema-detection guard required typeof object, so function-typed Standard Schemas (e.g. ArkType) fell through to the raw branch and the vendor-internal object shipped as requestedSchema with no conversion or flat-primitive gate. Delegate to isStandardSchema, which accepts functions and verifies ~standard.validate — and, matching the precedent in normalizeRawShapeSchema, does not gate on ~standard.jsonSchema so the zod 4.0/4.1 fallback stays reachable. Also fixes the reverse misroute: a plain JSON requestedSchema containing a literal '~standard' key is wire-legal and now stays on the raw branch instead of throwing a vendor error. --- .../core-internal/src/shared/elicitation.ts | 9 +++- .../test/shared/elicitation.test.ts | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index 8e6407e6d4..81b39f9622 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -6,7 +6,7 @@ import { ElicitRequestFormParamsSchema } from '../types/schemas'; import type { ElicitRequestFormParams } from '../types/types'; import { parseSchema } from '../util/schema'; import type { StandardSchemaWithJSON } from '../util/standardSchema'; -import { standardSchemaToJsonSchema } from '../util/standardSchema'; +import { isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema'; import type { ElicitInputFormParams } from './protocol'; export type NormalizedElicitInputFormParams = { @@ -154,7 +154,12 @@ function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = function isElicitInputSchema( schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON ): schema is StandardSchemaWithJSON { - return typeof schema === 'object' && schema !== null && '~standard' in schema; + // isStandardSchema, not a plain '~standard' key probe: ArkType schemas are functions + // (a typeof-object check routes them, unconverted, to the raw branch), and a plain JSON + // schema containing a literal '~standard' key — wire-legal via the catchall — must stay + // on the raw branch. Not isStandardSchemaWithJSON: gating on `~standard.jsonSchema` here + // would front-run standardSchemaToJsonSchema's zod 4.0/4.1 fallback (see zodCompat.ts). + return isStandardSchema(schema); } function convertStandardElicitationSchema(standardSchema: StandardSchemaWithJSON): Record { diff --git a/packages/core-internal/test/shared/elicitation.test.ts b/packages/core-internal/test/shared/elicitation.test.ts index 471f75994a..14003e79a8 100644 --- a/packages/core-internal/test/shared/elicitation.test.ts +++ b/packages/core-internal/test/shared/elicitation.test.ts @@ -19,6 +19,51 @@ function schemaFromJson(jsonSchema: Record): StandardSchemaWith } satisfies StandardSchemaWithJSON; } +describe('normalizeElicitInputFormParams schema detection', () => { + test('converts function-typed Standard Schemas (ArkType-style)', () => { + const jsonSchema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }; + const functionSchema = Object.assign(() => {}, { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + output: () => ({}) + } + } + }); + + const { params, standardSchema } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: functionSchema as unknown as StandardSchemaWithJSON + }); + + expect(standardSchema).toBe(functionSchema); + expect(params.requestedSchema).toEqual(jsonSchema); + }); + + test('routes a plain JSON schema containing a literal "~standard" key to the raw branch', () => { + const rawSchema = { + type: 'object' as const, + properties: { name: { type: 'string' as const } }, + '~standard': 'extension-data' + }; + + const { params, standardSchema } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: rawSchema as never + }); + + expect(standardSchema).toBeUndefined(); + expect(params.requestedSchema).toBe(rawSchema); + }); +}); + describe('normalizeElicitInputFormParams root keywords', () => { test('keeps the spec-declared root keys including $schema', () => { const { params } = normalizeElicitInputFormParams({