From 8b473a66ce350a1f37c42c49c2b580da96a1319c 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 4bdca965de685c147fce931302851514f28e4f8e 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 06f9b829f4..e082cd4b26 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -49,6 +49,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 e1f83f67d070d2749bec860b4173953ec5bfadcb 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 74001375705747fc0429f72007f78f647167cf60 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 14eb6abb2ec7a11db78cd2f3b5fd8f07b8b74990 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 4fd56f516ab4edf3f0a753dc0d80f19d188eb615 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 0370324624e49090a318fad15df020e20b1d8438 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 738f65cdba26ee3860ec62fc1b31e19a29625bc3 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 3687e95e2344f57d21e874a80bf72e1b44025c65 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 8c7528111ae0cbf6af340c727ac51634deea410a 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 7b9fe011ba151a4cc881698be1f42618c274c3c6 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 058de16174..56fc7cb310 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 709ca59f750fe12abe3533600b52ad0d0168f640 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 481c112a85d730b24cc0f6c70343a74075457ec6 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 7 Jul 2026 09:55:05 +0100 Subject: [PATCH 13/14] starting again lol --- .changeset/standard-schema-elicitation.md | 4 +- docs/servers/input-required.md | 10 +- .../guides/servers/input-required.examples.ts | 8 +- .../core-internal/src/exports/public/index.ts | 2 - packages/core-internal/src/index.ts | 1 - .../core-internal/src/shared/elicitation.ts | 155 +++-------- .../core-internal/src/shared/inputRequired.ts | 30 +-- packages/core-internal/src/shared/protocol.ts | 22 +- .../test/shared/inputRequired.test.ts | 41 ++- packages/server/src/index.ts | 2 +- packages/server/src/server/server.ts | 77 ++---- .../server/test/server/inputRequired.test.ts | 19 +- .../jsonSchemaValidatorOverride.test.ts | 240 +----------------- .../server/legacyInputRequiredShim.test.ts | 18 +- 14 files changed, 153 insertions(+), 476 deletions(-) diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md index ee0122362e..d4b1d86e06 100644 --- a/.changeset/standard-schema-elicitation.md +++ b/.changeset/standard-schema-elicitation.md @@ -1,8 +1,6 @@ --- '@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 -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`. +Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry. diff --git a/docs/servers/input-required.md b/docs/servers/input-required.md index 6f731504f9..595260f1e5 100644 --- a/docs/servers/input-required.md +++ b/docs/servers/input-required.md @@ -10,6 +10,10 @@ An **`input_required`** result is how a `tools/call`, `prompts/get`, or `resourc The handler reads what already arrived with `acceptedContent`; while the answer is missing it returns `inputRequired(...)` instead of a tool result. ```ts source="../../examples/guides/servers/input-required.examples.ts#registerTool_inputRequired" +const confirmationSchema = z.object({ + confirm: z.boolean().meta({ title: 'Confirm deployment' }) +}); + server.registerTool( 'deploy', { @@ -17,13 +21,13 @@ server.registerTool( inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx): Promise => { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + requestedSchema: confirmationSchema }) } }); @@ -33,7 +37,7 @@ server.registerTool( ); ``` -The first round returns `resultType: 'input_required'` carrying the `confirm` request. The client fulfils it and retries `deploy` with the answer in `inputResponses`; on re-entry `acceptedContent` finds it and the handler finishes. +The first round converts `confirmationSchema` to MCP's restricted elicitation JSON Schema and returns it inside `resultType: 'input_required'`. The client fulfils the request and retries `deploy`; on re-entry `acceptedContent` validates the answer with that same schema and the handler finishes. Every call on this page comes from an in-memory `Client` with an `elicitation/create` handler — [Test a server](../testing.md) shows that wiring. Calling `deploy` once produces both rounds: diff --git a/examples/guides/servers/input-required.examples.ts b/examples/guides/servers/input-required.examples.ts index bef7a1d403..8b3755ca17 100644 --- a/examples/guides/servers/input-required.examples.ts +++ b/examples/guides/servers/input-required.examples.ts @@ -36,6 +36,10 @@ const server = new McpServer({ name: 'releases', version: '1.0.0' }, { requestSt // "Return `input_required` instead of pushing a request" //#region registerTool_inputRequired +const confirmationSchema = z.object({ + confirm: z.boolean().meta({ title: 'Confirm deployment' }) +}); + server.registerTool( 'deploy', { @@ -43,13 +47,13 @@ server.registerTool( inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx): Promise => { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + requestedSchema: confirmationSchema }) } }); diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index e082cd4b26..06f9b829f4 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -49,8 +49,6 @@ export { getDisplayName } from '../../shared/metadataUtils'; export type { BaseContext, ClientContext, - ElicitInputFormParams, - ElicitInputResult, NotificationOptions, ProgressCallback, ProtocolOptions, diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 56fc7cb310..058de16174 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -4,7 +4,6 @@ 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/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index 8550759f85..2a356a3737 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -4,82 +4,28 @@ 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'; +import { isStandardSchemaWithJSON, standardSchemaToJsonSchema } from '../util/standardSchema'; -export type NormalizedElicitInputFormParams = { - params: ElicitRequestFormParams; - standardSchema?: StandardSchemaWithJSON; +/** Input accepted by {@linkcode inputRequired.elicit}. */ +export type ElicitInputParams = Omit & { + mode?: 'form'; + requestedSchema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON; }; 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}`); -} - -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' || - typeof original.pattern !== 'string' || - parsed.type !== 'string' || - typeof parsed.format !== 'string' || - original.format !== parsed.format - ) { - return false; - } - - if (parsed.format === 'date-time') { - return isZodIsoDatetimePattern(original.pattern); +function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Record { + try { + return standardSchemaToJsonSchema(schema, '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}` + ); } - - 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']); @@ -88,9 +34,14 @@ function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean { return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-'); } -function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] { +/** + * Finds converted keywords that MCP's restricted elicitation schema removed. + * Annotation-only metadata may be dropped; validation constraints may not be + * weakened silently. + */ +function findStrippedConstraintPaths(original: unknown, parsed: unknown, path = ''): string[] { if (Array.isArray(original) && Array.isArray(parsed)) { - return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`)); + return original.flatMap((item, index) => findStrippedConstraintPaths(item, parsed[index], `${path}[${index}]`)); } if (!isJsonObject(original) || !isJsonObject(parsed)) { @@ -100,61 +51,35 @@ 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) || isAnnotationOnlyJsonSchemaKeyword(key)) { - return []; - } - return [childPath]; + return isAnnotationOnlyJsonSchemaKeyword(key) ? [] : [childPath]; } - return findStrippedJsonSchemaPaths(value, parsed[key], childPath); + return findStrippedConstraintPaths(value, parsed[key], childPath); }); } -function isElicitInputSchema( - schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON -): schema is StandardSchemaWithJSON { - return typeof schema === 'object' && schema !== null && '~standard' in schema; -} +/** Converts an authoring-friendly elicitation input into its wire-ready form. */ +export function normalizeElicitInputParams(input: ElicitInputParams): ElicitRequestFormParams { + if (!isStandardSchemaWithJSON(input.requestedSchema)) { + return { ...input, mode: 'form', requestedSchema: input.requestedSchema }; + } -function convertStandardElicitationSchema(standardSchema: StandardSchemaWithJSON): Record { - try { - return standardSchemaToJsonSchema(standardSchema, 'input'); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); + const convertedSchema = convertStandardElicitationSchema(input.requestedSchema); + const normalized = { ...input, mode: 'form' as const, requestedSchema: convertedSchema }; + const parsed = parseSchema(ElicitRequestFormParamsSchema, normalized); + if (!parsed.success) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Elicitation requestedSchema must describe an object with flat primitive properties: ${detail}` + `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsed.error.message}` ); } -} -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: convertStandardElicitationSchema(standardSchema) - }; - 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 }; + const strippedConstraints = findStrippedConstraintPaths(convertedSchema, parsed.data.requestedSchema); + if (strippedConstraints.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${strippedConstraints.join(', ')}` + ); } - return { params: formParams }; + return parsed.data; } diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 09f8255985..4a9fe618dd 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -17,12 +17,12 @@ * 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, CreateMessageResult, CreateMessageResultWithTools, - ElicitRequestFormParams, ElicitRequestURLParams, InputRequest, InputRequests, @@ -30,9 +30,11 @@ import type { InputResponses, Root } from '../types/types'; -import type { StandardSchemaV1, StandardSchemaWithJSON } from '../util/standardSchema'; -import { normalizeElicitInputFormParams } from './elicitation'; -import type { ElicitInputFormParams } from './protocol'; +import type { StandardSchemaV1 } from '../util/standardSchema'; +import type { ElicitInputParams } from './elicitation'; +import { normalizeElicitInputParams } from './elicitation'; + +export type { ElicitInputParams } from './elicitation'; /** The shape accepted by {@linkcode inputRequired}. */ export interface InputRequiredSpec { @@ -60,10 +62,7 @@ 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; + elicit(params: ElicitInputParams): InputRequest; /** * Builds an embedded URL-mode elicitation request (`elicitation/create`). @@ -123,15 +122,12 @@ function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult { * ``` */ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequired, { - elicit( - params: - | (Omit & { mode?: 'form' }) - | (Omit, 'mode'> & { mode?: 'form' }) - ): InputRequest { - const { params: normalizedParams } = normalizeElicitInputFormParams( - params as ElicitRequestFormParams | ElicitInputFormParams - ); - return { method: 'elicitation/create', params: normalizedParams }; + elicit(params: ElicitInputParams): InputRequest { + try { + return { method: 'elicitation/create', params: normalizeElicitInputParams(params) }; + } catch (error) { + 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/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 25e78b41f4..ffab6e8df8 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, StandardSchemaWithJSON } from '../util/standardSchema'; +import type { StandardSchemaV1 } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; import { bootstrapOutboundCodec } from '../wire/bootstrap'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec'; @@ -444,18 +444,6 @@ 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. */ @@ -479,13 +467,7 @@ 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: ElicitInputFormParams, - options?: RequestOptions - ): Promise>; - (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; - }; + elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; /** * Request LLM sampling from the client. diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index cbc08f93ea..b59a9f02da 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'; @@ -53,22 +52,30 @@ describe('inputRequired() builder', () => { expect(inputRequired.listRoots()).toEqual({ method: 'roots/list' }); }); - test('elicit converts Standard Schema requestedSchema to elicitation JSON Schema', () => { + test('elicit converts a Standard Schema to the restricted wire schema', () => { const request = inputRequired.elicit({ - message: 'Email?', - requestedSchema: z.object({ email: z.email() }) + message: 'Registration details?', + requestedSchema: z.object({ + email: z.string().meta({ title: 'Email', format: 'email' }), + count: z.number().min(1).max(5), + role: z.enum(['admin', 'member']) + }) }); expect(request).toEqual({ method: 'elicitation/create', params: { mode: 'form', - message: 'Email?', + message: 'Registration details?', requestedSchema: { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', - properties: { email: { type: 'string', format: 'email' } }, - required: ['email'] + properties: { + email: { type: 'string', title: 'Email', format: 'email' }, + count: { type: 'number', minimum: 1, maximum: 5 }, + role: { type: 'string', enum: ['admin', 'member'] } + }, + required: ['email', 'count', 'role'] } } }); @@ -103,13 +110,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 local type error', () => { expect(() => inputRequired.elicit({ message: 'Name?', requestedSchema: z.string() }) - ).toThrow(ProtocolError); + ).toThrow(TypeError); expect(() => inputRequired.elicit({ message: 'Name?', @@ -117,6 +124,22 @@ describe('inputRequired() builder', () => { }) ).toThrow(/Elicitation requestedSchema must describe an object/); }); + + test('elicit rejects validation constraints the restricted wire schema cannot express', () => { + expect(() => + inputRequired.elicit({ + message: 'Code?', + requestedSchema: z.object({ code: z.string().regex(/^[A-Z]{3}$/) }) + }) + ).toThrow(/properties\.code\.pattern/); + + expect(() => + inputRequired.elicit({ + message: 'Address?', + requestedSchema: z.object({ address: z.object({ city: z.string() }) }) + }) + ).toThrow(/flat primitive properties/); + }); }); describe('acceptedContent()', () => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dd720eb954..0d4b7c0484 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -96,7 +96,7 @@ export type { CacheHint, CacheScope } from '@modelcontextprotocol/core-internal' // helpers a handler uses to request additional client input by returning an // input-required result instead of sending a server→client request, and the // typed readers for the responses a retried request carries back. -export type { InputRequiredSpec, InputResponseView } from '@modelcontextprotocol/core-internal'; +export type { ElicitInputParams, InputRequiredSpec, InputResponseView } from '@modelcontextprotocol/core-internal'; export { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal'; // re-export curated public API from core diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 906048abc7..4151849494 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -10,8 +10,6 @@ import type { CreateMessageResult, CreateMessageResultWithTools, DiscoverResult, - ElicitInputFormParams, - ElicitInputResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, @@ -36,7 +34,6 @@ import type { Result, ServerCapabilities, ServerContext, - StandardSchemaWithJSON, ToolResultContent, ToolUseContent } from '@modelcontextprotocol/core-internal'; @@ -55,14 +52,12 @@ import { missingClientCapabilities, MissingRequiredClientCapabilityError, modernProtocolVersions, - normalizeElicitInputFormParams, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, - validateStandardSchema, withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -405,7 +400,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: this.elicitInput.bind(this) as ServerContext['mcpReq']['elicitInput'], + elicitInput: (params, options) => this.elicitInput(params, options), requestSampling: (params, options) => this.createMessage(params, options) }, http: hasHttpInfo @@ -1132,27 +1127,7 @@ 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: 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, - options?: RequestOptions - ): Promise> { + async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { this._assertPushApiInServedEra('elicitation/create'); const mode = (params.mode ?? 'form') as 'form' | 'url'; @@ -1183,10 +1158,10 @@ export class Server extends Protocol { * `acceptedContent` overload and can re-ask). */ private async _sendElicitationLeg( - params: ElicitRequestFormParams | ElicitRequestURLParams | ElicitInputFormParams, + params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions, behavior?: { validateAcceptedContent: boolean } - ): Promise> { + ): Promise { const mode = (params.mode ?? 'form') as 'form' | 'url'; const validateAcceptedContent = behavior?.validateAcceptedContent ?? true; @@ -1198,42 +1173,30 @@ export class Server extends Protocol { return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { - const { params: formParams, standardSchema } = normalizeElicitInputFormParams( - params as ElicitRequestFormParams | ElicitInputFormParams - ); + const formParams: ElicitRequestFormParams = + params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; const result = await this.request({ method: 'elicitation/create', params: formParams }, options); - if (validateAcceptedContent && result.action === 'accept' && result.content !== undefined && formParams.requestedSchema) { - if (standardSchema) { - const parsedContent = await validateStandardSchema(standardSchema, result.content); - if (!parsedContent.success) { + 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) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${parsedContent.error}` + `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` ); } - 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)}` - ); + } catch (error) { + if (error instanceof ProtocolError) { + throw error; } + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` + ); } } return result; diff --git a/packages/server/test/server/inputRequired.test.ts b/packages/server/test/server/inputRequired.test.ts index 22dde018b8..8dfae39b63 100644 --- a/packages/server/test/server/inputRequired.test.ts +++ b/packages/server/test/server/inputRequired.test.ts @@ -119,17 +119,18 @@ function errorOf(message: JSONRPCMessage): { code: number; message: string; data describe('input-required returns on the 2026-07-28 era', () => { it('a write-once tool returning inputRequired() reaches the wire as input_required and completes on the retry', async () => { const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + const confirmationSchema = z.object({ confirm: z.boolean().meta({ title: 'Confirm deployment' }) }); server.registerTool( 'deploy', { inputSchema: z.object({ env: z.string() }), outputSchema: z.object({ deployed: z.boolean() }) }, async ({ env }, ctx) => { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema); if (!confirmed?.confirm) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } } } + requestedSchema: confirmationSchema }) }, requestState: 'opaque-deploy-state' @@ -147,7 +148,19 @@ describe('input-required returns on the 2026-07-28 era', () => { ); expect(first.resultType).toBe('input_required'); expect(first.requestState).toBe('opaque-deploy-state'); - expect(first.inputRequests).toMatchObject({ confirm: { method: 'elicitation/create' } }); + expect(first.inputRequests).toMatchObject({ + confirm: { + method: 'elicitation/create', + params: { + mode: 'form', + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Confirm deployment' } }, + required: ['confirm'] + } + } + } + }); expect(first.ttlMs).toBeUndefined(); expect(first.cacheScope).toBeUndefined(); expect(first.content).toBeUndefined(); diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 6f4e2ce4bb..e5edf18398 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -1,14 +1,5 @@ +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, - StandardSchemaWithJSON, - jsonSchemaValidator -} from '../../src/index'; import { fromJsonSchema } from '../../src/fromJsonSchema'; import { Server } from '../../src/server/server'; @@ -89,235 +80,6 @@ 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: { count: '5', email: 'user@example.com', startsAt: '2026-01-02T03:04:05+01:00' } - } - }); - } - }; - - const schema = z.object({ - count: z.coerce.number().min(1).meta({ title: 'Registration Count', description: 'Number of registrations to process' }), - email: z.email().meta({ title: 'Email', description: 'Email address' }), - startsAt: z.iso.datetime({ offset: true }).meta({ title: 'Start Time' }), - newsletter: z.boolean().default(false) - }); - - const params = { - message: 'How many registrations?', - requestedSchema: schema - } satisfies ElicitInputFormParams; - - const result = await server.elicitInput(params); - - expectTypeOf(result).toMatchTypeOf>(); - 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: { - count: { - type: 'number', - minimum: 1, - title: 'Registration Count', - description: 'Number of registrations to process' - }, - email: { - type: 'string', - format: 'email', - title: 'Email', - description: 'Email address' - }, - startsAt: { - type: 'string', - format: 'date-time', - title: 'Start Time' - }, - newsletter: { type: 'boolean', default: false } - }, - 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([]); - - 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' } - } - }); - 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 expect( - server.elicitInput({ - message: 'What is your ID?', - requestedSchema: z.object({ - id: z.string().uuid() - }) - }) - ).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: 'What is your email?', - requestedSchema: z.object({ - email: z.email({ pattern: /@corp\.com$/ }) - }) - }) - ).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?', - 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(); - }); - test('fromJsonSchema uses an explicitly supplied custom validator', async () => { const validator = new RecordingValidator(); const schema: JsonSchemaType = { diff --git a/packages/server/test/server/legacyInputRequiredShim.test.ts b/packages/server/test/server/legacyInputRequiredShim.test.ts index 62d640b179..ce5a384df8 100644 --- a/packages/server/test/server/legacyInputRequiredShim.test.ts +++ b/packages/server/test/server/legacyInputRequiredShim.test.ts @@ -35,19 +35,21 @@ import { errorOf, legacyInitialize, resultOf, toolText, wireLegacy } from './leg import { McpServer } from '../../src/server/mcp'; import { Server } from '../../src/server/server'; +const CONFIRM_SCHEMA = z.object({ confirm: z.boolean().meta({ title: 'Confirm deployment' }) }); + /** A sessionful legacy connection serving one write-once elicitation tool. */ async function elicitingToolServer(options?: ConstructorParameters[1]) { const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options }); const seenResponses: Array | undefined> = []; server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { seenResponses.push(ctx.mcpReq.inputResponses); - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', CONFIRM_SCHEMA); if (!confirmed?.confirm) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + requestedSchema: CONFIRM_SCHEMA }) } }); @@ -82,10 +84,18 @@ describe('legacy shim: write-once fulfilment on a sessionful 2025-era connection // The mis-typed input_required result never reached the wire. expect(resultOf(answer).resultType).toBeUndefined(); - // A real wire request went out, form-mode, with the handler's params. + // A real wire request went out, form-mode, with the Standard Schema converted to MCP's restricted JSON Schema. const legs = wire.peerRequests('elicitation/create'); expect(legs).toHaveLength(1); - expect(legs[0]!.params).toMatchObject({ mode: 'form', message: 'Deploy to prod?' }); + expect(legs[0]!.params).toMatchObject({ + mode: 'form', + message: 'Deploy to prod?', + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Confirm deployment' } }, + required: ['confirm'] + } + }); // Stream association: the leg is stamped with the ORIGINATING request id. const [legOptions] = wire.sentOptionsFor('elicitation/create'); From 43d2b311f5c3306c11941e33680494666f621057 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 7 Jul 2026 10:00:46 +0100 Subject: [PATCH 14/14] fix(docs): avoid unresolved inputRequired link --- packages/core-internal/src/shared/elicitation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index 2a356a3737..b322f4f8f2 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -6,7 +6,7 @@ import { parseSchema } from '../util/schema'; import type { StandardSchemaWithJSON } from '../util/standardSchema'; import { isStandardSchemaWithJSON, standardSchemaToJsonSchema } from '../util/standardSchema'; -/** Input accepted by {@linkcode inputRequired.elicit}. */ +/** Input accepted by `inputRequired.elicit()`. */ export type ElicitInputParams = Omit & { mode?: 'form'; requestedSchema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON;