diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md new file mode 100644 index 000000000..ee0122362 --- /dev/null +++ b/.changeset/standard-schema-elicitation.md @@ -0,0 +1,8 @@ +--- +'@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`. diff --git a/docs/migration.md b/docs/migration.md index 1b6062225..13e1ca2a4 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -693,10 +693,18 @@ server.setRequestHandler('tools/call', async (request, ctx) => { requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } }); + // Or pass a Standard Schema such as a Zod object for typed content. + const typedElicitResult = await ctx.mcpReq.elicitInput({ + message: 'Please provide details', + requestedSchema: z.object({ name: z.string().meta({ title: 'Name' }) }) + }); + return { content: [{ type: 'text', text: 'done' }] }; }); ``` +Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels. Zod string helpers that emit the supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary `.regex()` patterns are rejected because form elicitation does not carry JSON Schema `pattern`. + These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers. ### Error hierarchy refactoring diff --git a/docs/server.md b/docs/server.md index 468bf0cb2..231affe02 100644 --- a/docs/server.md +++ b/docs/server.md @@ -497,6 +497,11 @@ Elicitation lets a tool handler request direct input from the user — form fiel > [!IMPORTANT] > Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. +For form elicitation, pass either the restricted JSON Schema shape used by the MCP wire protocol or a Standard Schema such as a Zod object. Standard Schemas are converted to the restricted elicitation JSON Schema before being sent, so they must describe a flat object with +primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. With Zod v4, use `.meta({ title: 'Field Label' })` for short +form-field labels; `.describe()` maps to JSON Schema `description`, not `title`. Zod string helpers that emit the supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary `.regex()` patterns are rejected because form elicitation does not carry JSON Schema +`pattern`. + Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: ```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_elicitation" @@ -510,19 +515,10 @@ server.registerTool( const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Please share your feedback:', - requestedSchema: { - type: 'object', - properties: { - rating: { - type: 'number', - title: 'Rating (1\u20135)', - minimum: 1, - maximum: 5 - }, - comment: { type: 'string', title: 'Comment' } - }, - required: ['rating'] - } + requestedSchema: z.object({ + rating: z.number().min(1).max(5).meta({ title: 'Rating (1-5)' }), + comment: z.string().optional().meta({ title: 'Comment' }) + }) }); if (result.action === 'accept') { return { diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts index e059e8452..23d7cff09 100644 --- a/examples/server/src/elicitationFormExample.ts +++ b/examples/server/src/elicitationFormExample.ts @@ -13,6 +13,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; // Create a fresh MCP server per client connection to avoid shared state between clients. // The validator supports format validation (email, date, etc.) if ajv-formats is installed. @@ -38,51 +39,23 @@ const getServer = () => { }, async () => { try { + const registrationSchema = z.object({ + username: z.string().min(3).max(20).meta({ title: 'Username', description: 'Your desired username (3-20 characters)' }), + email: z.email().meta({ title: 'Email', description: 'Your email address' }), + password: z.string().min(8).meta({ title: 'Password', description: 'Your password (min 8 characters)' }), + newsletter: z.boolean().default(false).meta({ title: 'Newsletter', description: 'Subscribe to newsletter?' }) + }); + // Request user information through form elicitation const result = await mcpServer.server.elicitInput({ mode: 'form', message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your desired username (3-20 characters)', - minLength: 3, - maxLength: 20 - }, - email: { - type: 'string', - title: 'Email', - description: 'Your email address', - format: 'email' - }, - password: { - type: 'string', - title: 'Password', - description: 'Your password (min 8 characters)', - minLength: 8 - }, - newsletter: { - type: 'boolean', - title: 'Newsletter', - description: 'Subscribe to newsletter?', - default: false - } - }, - required: ['username', 'email', 'password'] - } + requestedSchema: registrationSchema }); // Handle the different possible actions if (result.action === 'accept' && result.content) { - const { username, email, newsletter } = result.content as { - username: string; - email: string; - password: string; - newsletter?: boolean; - }; + const { username, email, newsletter } = result.content; return { content: [ diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index ec9eed210..a286b204c 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -418,19 +418,10 @@ function registerTool_elicitation(server: McpServer) { const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Please share your feedback:', - requestedSchema: { - type: 'object', - properties: { - rating: { - type: 'number', - title: 'Rating (1\u20135)', - minimum: 1, - maximum: 5 - }, - comment: { type: 'string', title: 'Comment' } - }, - required: ['rating'] - } + requestedSchema: z.object({ + rating: z.number().min(1).max(5).meta({ title: 'Rating (1-5)' }), + comment: z.string().optional().meta({ title: 'Comment' }) + }) }); if (result.action === 'accept') { return { diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a8..196a36750 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 88d4942a7..a1572d891 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -44,6 +44,8 @@ export { getDisplayName } from '../../shared/metadataUtils'; export type { BaseContext, ClientContext, + ElicitInputFormParams, + ElicitInputResult, NotificationOptions, ProgressCallback, ProtocolOptions, diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 3b7efec2a..a9f961a34 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -42,7 +42,7 @@ import { ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index'; -import type { StandardSchemaV1 } from '../util/standardSchema'; +import type { StandardSchemaV1, StandardSchemaWithJSON } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; import type { Transport, TransportSendOptions } from './transport'; @@ -203,6 +203,18 @@ export type BaseContext = { }; }; +export type ElicitInputFormParams = Omit< + ElicitRequestFormParams, + 'requestedSchema' +> & { + requestedSchema: Schema; +}; + +export type ElicitInputResult = Result & { + action: ElicitResult['action']; + content?: StandardSchemaWithJSON.InferOutput; +}; + /** * Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields. */ @@ -221,7 +233,13 @@ export type ServerContext = BaseContext & { /** * Send an elicitation request to the client, requesting user input. */ - elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; + elicitInput: { + ( + params: ElicitInputFormParams, + options?: RequestOptions + ): Promise>; + (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; + }; /** * Request LLM sampling from the client. diff --git a/packages/server/src/server/elicitation.ts b/packages/server/src/server/elicitation.ts new file mode 100644 index 000000000..d05821a9e --- /dev/null +++ b/packages/server/src/server/elicitation.ts @@ -0,0 +1,142 @@ +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_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); + } + + 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 b0777d118..f3f9f87ce 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -6,6 +6,8 @@ import type { CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + ElicitInputFormParams, + ElicitInputResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, @@ -28,6 +30,7 @@ import type { Result, ServerCapabilities, ServerContext, + StandardSchemaWithJSON, ToolResultContent, ToolUseContent } from '@modelcontextprotocol/core-internal'; @@ -47,10 +50,13 @@ import { ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode + SdkErrorCode, + validateStandardSchema } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import { normalizeElicitInputFormParams } from './elicitation'; + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -151,7 +157,7 @@ export class Server extends Protocol { // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), - elicitInput: (params, options) => this.elicitInput(params, options), + elicitInput: this.elicitInput.bind(this) as ServerContext['mcpReq']['elicitInput'], requestSampling: (params, options) => this.createMessage(params, options) }, http: hasHttpInfo @@ -525,7 +531,15 @@ export class Server extends Protocol { * @param options Optional request options. * @returns The result of the elicitation request. */ - async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + async elicitInput( + params: ElicitInputFormParams, + options?: RequestOptions + ): Promise>; + async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; + async elicitInput( + params: ElicitRequestFormParams | ElicitRequestURLParams | ElicitInputFormParams, + options?: RequestOptions + ): Promise> { const mode = (params.mode ?? 'form') as 'form' | 'url'; switch (mode) { @@ -542,8 +556,9 @@ export class Server extends Protocol { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); } - const formParams: ElicitRequestFormParams = - params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; + const { params: formParams, standardSchema } = normalizeElicitInputFormParams( + params as ElicitRequestFormParams | ElicitInputFormParams + ); const result = await this._requestWithSchema( { method: 'elicitation/create', params: formParams }, @@ -551,25 +566,36 @@ export class Server extends Protocol { options ); - if (result.action === 'accept' && result.content && formParams.requestedSchema) { - try { - const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); - const validationResult = validator(result.content); - - if (!validationResult.valid) { + if (result.action === 'accept' && result.content !== undefined && formParams.requestedSchema) { + if (standardSchema) { + const parsedContent = await validateStandardSchema(standardSchema, result.content); + if (!parsedContent.success) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + `Elicitation response content does not match requested schema: ${parsedContent.error}` ); } - } catch (error) { - if (error instanceof ProtocolError) { - throw error; + return { ...result, content: parsedContent.data }; + } else { + try { + const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); + const validationResult = validator(result.content); + + if (!validationResult.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof ProtocolError) { + throw error; + } + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` + ); } - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` - ); } } return result; diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index e5edf1839..6f4e2ce4b 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -1,5 +1,14 @@ -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'; @@ -80,6 +89,235 @@ 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 = {