diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md new file mode 100644 index 0000000000..d746c314de --- /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`. The converted schema's root is held to the spec's shape (`type`, `properties`, `required`, `$schema`): unknown root keywords — such as the `additionalProperties` emitted by `z.strictObject()` — are rejected before sending, and annotation-only root keywords (`title`, `description`) are dropped. diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 0a50e00dd5..b4ed761bbf 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -46,6 +46,8 @@ export { getDisplayName } from '../../shared/metadataUtils'; export type { BaseContext, ClientContext, + ElicitInputFormParams, + ElicitInputResult, NotificationOptions, ProgressCallback, ProtocolOptions, diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 7ea56117c7..7eede8aa5f 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -4,6 +4,7 @@ export * from './errors/sdkErrors'; export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; +export * from './shared/elicitation'; export * from './shared/envelope'; export * from './shared/inboundClassification'; export * from './shared/inputRequired'; diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts new file mode 100644 index 0000000000..81b39f9622 --- /dev/null +++ b/packages/core-internal/src/shared/elicitation.ts @@ -0,0 +1,207 @@ +import * as z from 'zod/v4'; + +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 { isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema'; +import type { ElicitInputFormParams } from './protocol'; + +export type NormalizedElicitInputFormParams = { + params: ElicitRequestFormParams; + standardSchema?: StandardSchemaWithJSON; +}; + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// A `pattern` emitted beside a supported `format` is redundant — zod realizes every format +// check as a regex — and is dropped so the wire schema stays within the elicitation subset. +// The reference patterns are derived from the installed zod at runtime rather than vendored +// as string literals: zod's format regexes change across in-range releases, so a vendored +// copy would start rejecting schemas produced by any newer zod while CI (lockfile-pinned) +// stays green. A pattern the installed zod would not emit for that format — e.g. a +// customized `z.email({ pattern })` — still rejects, because the wire schema cannot carry it. +// Residual limitation: if the app resolves a second zod copy whose regexes differ from this +// package's resolved zod, its emissions won't match the reference and reject (fail closed); +// zod is a peer dependency precisely so installs dedupe to one copy. +// Derivation is cheap (a handful of toJSONSchema calls) and only runs when a stripped +// `pattern` sits beside a matching `format`, so no caching. + +function zodEmittedPattern(schema: z.ZodType): string | undefined { + // Conversion options must stay in lockstep with standardSchemaToJsonSchema (which + // produced the pattern under comparison via `~standard.jsonSchema.input`). + const jsonSchema = z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' }) as Record; + return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined; +} + +const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/; + +function datetimeReferenceSchemas(pattern: string): z.ZodType[] { + // The emitted pattern depends on the authoring options (offset/local/precision); the + // fraction-digit count recovered from the pattern under test keeps the candidate set + // finite. Duplicate candidates are fine — the result feeds a Set. + const fractionDigits = DATETIME_FRACTION_DIGITS.exec(pattern); + const precisions: Array = [undefined, -1, 0]; + if (fractionDigits) { + precisions.push(Number(fractionDigits[1])); + } + return [false, true].flatMap(local => + [false, true].flatMap(offset => precisions.map(precision => z.iso.datetime({ local, offset, precision }))) + ); +} + +function referencePatternsForFormat(format: string, pattern: string): ReadonlySet { + let referenceSchemas: z.ZodType[]; + switch (format) { + case 'email': { + referenceSchemas = [z.email()]; + break; + } + case 'uri': { + referenceSchemas = [z.url()]; + break; + } + case 'date': { + referenceSchemas = [z.iso.date()]; + break; + } + case 'date-time': { + referenceSchemas = datetimeReferenceSchemas(pattern); + break; + } + default: { + referenceSchemas = []; + } + } + return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined)); +} + +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 referencePatternsForFormat(parsed.format, original.pattern).has(original.pattern); +} + +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-'); +} + +// The spec declares a closed shape for the `requestedSchema` root: `$schema`, `type`, +// `properties` and `required` only. The wire schema cannot enforce that (its root is a +// catchall so hand-authored extensions stay wire-legal), and the stripped-keys diff below +// never fires for root keys the catchall retains — so converted Standard Schemas are pruned +// here: annotation-only root keywords are dropped, anything else unknown rejects (e.g. +// `z.strictObject()` emits a root `additionalProperties: false`). The keyword set is derived +// from the wire schema so it tracks spec revisions; `$schema` is spec-declared but absent +// from the wire schema's declared keys (the catchall admits it). +const ELICITATION_ROOT_KEYWORDS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]); +const ROOT_ANNOTATION_KEYWORDS = new Set(['title', 'description']); + +function pruneElicitationSchemaRoot(schema: Record): Record { + const requestedSchema: Record = {}; + const unsupportedKeys: string[] = []; + for (const [key, value] of Object.entries(schema)) { + if (ELICITATION_ROOT_KEYWORDS.has(key)) { + requestedSchema[key] = value; + } else if (!ROOT_ANNOTATION_KEYWORDS.has(key) && !isAnnotationOnlyJsonSchemaKeyword(key)) { + unsupportedKeys.push(key); + } + } + if (unsupportedKeys.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${unsupportedKeys.join(', ')}` + ); + } + return requestedSchema; +} + +function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] { + if (Array.isArray(original) && Array.isArray(parsed)) { + return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`)); + } + + 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) || isAnnotationOnlyJsonSchemaKeyword(key)) { + return []; + } + return [childPath]; + } + return findStrippedJsonSchemaPaths(value, parsed[key], childPath); + }); +} + +function isElicitInputSchema( + schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON +): schema is StandardSchemaWithJSON { + // isStandardSchema, not a plain '~standard' key probe: ArkType schemas are functions + // (a typeof-object check routes them, unconverted, to the raw branch), and a plain JSON + // schema containing a literal '~standard' key — wire-legal via the catchall — must stay + // on the raw branch. Not isStandardSchemaWithJSON: gating on `~standard.jsonSchema` here + // would front-run standardSchemaToJsonSchema's zod 4.0/4.1 fallback (see zodCompat.ts). + return isStandardSchema(schema); +} + +function convertStandardElicitationSchema(standardSchema: StandardSchemaWithJSON): Record { + 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 { + 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: pruneElicitationSchemaRoot(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 }; + } + + return { params: formParams }; +} diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 0c7ba043e1..6ae6a3bd02 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -17,6 +17,7 @@ * discriminator, and hand-built result literals are equally legal — the * server seam re-checks the at-least-one rule for them. */ +import { ProtocolError } from '../types/errors'; import { isInputRequiredResult } from '../types/guards'; import type { CreateMessageRequestParams, @@ -30,7 +31,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 +60,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 +124,22 @@ 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 { + try { + const { params: normalizedParams } = normalizeElicitInputFormParams( + params as ElicitRequestFormParams | ElicitInputFormParams + ); + return { method: 'elicitation/create', params: normalizedParams }; + } catch (error) { + // Authoring mistakes in the builder surface as TypeError, matching inputRequired() + // itself; a ProtocolError escaping a prompts/get or resources/read handler would + // reach the client as InvalidParams on its own request. + throw error instanceof ProtocolError ? new TypeError(error.message, { cause: error }) : error; + } }, elicitUrl(params: Omit): InputRequest { // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the diff --git a/packages/core-internal/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/core-internal/test/shared/elicitation.test.ts b/packages/core-internal/test/shared/elicitation.test.ts new file mode 100644 index 0000000000..14003e79a8 --- /dev/null +++ b/packages/core-internal/test/shared/elicitation.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { normalizeElicitInputFormParams } from '../../src/shared/elicitation'; +import { ProtocolError } from '../../src/types/errors'; +import type { StandardSchemaWithJSON } from '../../src/util/standardSchema'; + +function schemaFromJson(jsonSchema: Record): StandardSchemaWithJSON { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + output: () => ({}) + } + } + } satisfies StandardSchemaWithJSON; +} + +describe('normalizeElicitInputFormParams schema detection', () => { + test('converts function-typed Standard Schemas (ArkType-style)', () => { + const jsonSchema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }; + const functionSchema = Object.assign(() => {}, { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + output: () => ({}) + } + } + }); + + const { params, standardSchema } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: functionSchema as unknown as StandardSchemaWithJSON + }); + + expect(standardSchema).toBe(functionSchema); + expect(params.requestedSchema).toEqual(jsonSchema); + }); + + test('routes a plain JSON schema containing a literal "~standard" key to the raw branch', () => { + const rawSchema = { + type: 'object' as const, + properties: { name: { type: 'string' as const } }, + '~standard': 'extension-data' + }; + + const { params, standardSchema } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: rawSchema as never + }); + + expect(standardSchema).toBeUndefined(); + expect(params.requestedSchema).toBe(rawSchema); + }); +}); + +describe('normalizeElicitInputFormParams root keywords', () => { + test('keeps the spec-declared root keys including $schema', () => { + const { params } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: z.object({ name: z.string() }) + }); + + expect(params.requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + }); + + test('drops annotation-only root keywords', () => { + const { params } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: z.object({ name: z.string() }).meta({ title: 'User', description: 'User info', 'x-ui-hint': 'compact' }) + }); + + expect(params.requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + }); + + test.each([ + ['z.strictObject emits additionalProperties: false', z.strictObject({ name: z.string() }), /additionalProperties/], + ['z.looseObject emits additionalProperties: {}', z.looseObject({ name: z.string() }), /additionalProperties/], + [ + 'catchall emits a nested schema under additionalProperties', + z.object({ name: z.string() }).catchall(z.object({ x: z.string() })), + /additionalProperties/ + ], + ['a root default cannot ride the wire', z.object({ name: z.string() }).default({ name: 'x' }), /default/], + [ + 'a custom schema emitting $defs', + schemaFromJson({ + $defs: { Name: { type: 'string' } }, + type: 'object', + properties: { name: { $ref: '#/$defs/Name' } }, + required: ['name'] + }), + /\$defs/ + ] + ])('rejects unsupported root keywords: %s', (_label, requestedSchema, message) => { + const params = { message: 'Name?', requestedSchema } as Parameters[0]; + const act = () => normalizeElicitInputFormParams(params); + expect(act).toThrow(ProtocolError); + expect(act).toThrow(/unsupported JSON Schema keyword\(s\)/); + expect(act).toThrow(message); + }); +}); + +describe('normalizeElicitInputFormParams redundant format patterns', () => { + test.each([ + ['z.email()', z.email(), 'email'], + ['z.url()', z.url(), 'uri'], + ['z.iso.date()', z.iso.date(), 'date'], + ['z.iso.datetime()', z.iso.datetime(), 'date-time'], + ['z.iso.datetime({ offset: true })', z.iso.datetime({ offset: true }), 'date-time'], + ['z.iso.datetime({ local: true })', z.iso.datetime({ local: true }), 'date-time'], + ['z.iso.datetime({ precision: -1 })', z.iso.datetime({ precision: -1 }), 'date-time'], + ['z.iso.datetime({ precision: 0 })', z.iso.datetime({ precision: 0 }), 'date-time'], + ['z.iso.datetime({ precision: 3, offset: true })', z.iso.datetime({ precision: 3, offset: true }), 'date-time'] + ])('accepts %s and strips the zod-emitted pattern', (_label, fieldSchema, format) => { + const { params } = normalizeElicitInputFormParams({ + message: 'Value?', + requestedSchema: z.object({ value: fieldSchema }) + }); + + const valueSchema = (params.requestedSchema.properties as Record>).value!; + expect(valueSchema.format).toBe(format); + expect(valueSchema.pattern).toBeUndefined(); + }); + + test('accepts exactly the pattern the installed zod emits for a format', () => { + const emittedPattern = (z.toJSONSchema(z.email(), { target: 'draft-2020-12', io: 'input' }) as Record) + .pattern as string; + const { params } = normalizeElicitInputFormParams({ + message: 'Email?', + requestedSchema: schemaFromJson({ + type: 'object', + properties: { email: { type: 'string', format: 'email', pattern: emittedPattern } }, + required: ['email'] + }) + }); + + const emailSchema = (params.requestedSchema.properties as Record>).email!; + expect(emailSchema.format).toBe('email'); + expect(emailSchema.pattern).toBeUndefined(); + }); + + test.each([ + ['a customized email pattern', z.object({ email: z.email({ pattern: /@corp\.com$/ }) }), /properties\.email\.pattern/], + [ + 'a pattern the installed zod would not emit for the format', + schemaFromJson({ + type: 'object', + properties: { email: { type: 'string', format: 'email', pattern: '^different$' } }, + required: ['email'] + }), + /properties\.email\.pattern/ + ], + ['a pattern without a supported format', z.object({ code: z.string().regex(/^[A-Z]{3}$/) }), /properties\.code\.pattern/] + ])('rejects %s', (_label, requestedSchema, message) => { + const params = { message: 'Value?', requestedSchema } as Parameters[0]; + const act = () => normalizeElicitInputFormParams(params); + expect(act).toThrow(ProtocolError); + expect(act).toThrow(message); + }); +}); diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index 2bce27fcf1..e183b4bd83 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -51,6 +51,86 @@ 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 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 a TypeError', () => { + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.string() + }) + ).toThrow(TypeError); + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.string() + }) + ).toThrow(/Elicitation requestedSchema must describe an object/); + }); + + test('elicit rejects schemas outside the elicitation subset as a TypeError', () => { + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.strictObject({ name: z.string() }) + }) + ).toThrow(TypeError); + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.strictObject({ name: z.string() }) + }) + ).toThrow(/unsupported JSON Schema keyword\(s\).*additionalProperties/); + }); }); describe('acceptedContent()', () => { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4151849494..906048abc7 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'; @@ -52,12 +55,14 @@ import { missingClientCapabilities, MissingRequiredClientCapabilityError, modernProtocolVersions, + normalizeElicitInputFormParams, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, + validateStandardSchema, withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -400,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 @@ -1127,7 +1132,27 @@ 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>; + /** + * 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> { this._assertPushApiInServedEra('elicitation/create'); const mode = (params.mode ?? 'form') as 'form' | 'url'; @@ -1158,10 +1183,10 @@ 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 { + ): Promise> { const mode = (params.mode ?? 'form') as 'form' | 'url'; const validateAcceptedContent = behavior?.validateAcceptedContent ?? true; @@ -1173,30 +1198,42 @@ 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 { params: formParams, standardSchema } = 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; diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index e5edf18398..b9bb4ad674 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,245 @@ 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: 'What is your name?', + requestedSchema: z.strictObject({ + name: z.string() + }) + }) + ).rejects.toThrow(/unsupported JSON Schema keyword\(s\).*additionalProperties/); + expect(sawElicitationRequest).toBe(false); + + await expect( + server.elicitInput({ + message: 'How many?', + 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 = {