Skip to content
8 changes: 8 additions & 0 deletions .changeset/standard-schema-elicitation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/client': minor
---
Comment thread
mattzcarey marked this conversation as resolved.

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`.
8 changes: 8 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 9 additions & 13 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down
47 changes: 10 additions & 37 deletions examples/server/src/elicitationFormExample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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?' })
});
Comment thread
mattzcarey marked this conversation as resolved.

// 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
Comment thread
claude[bot] marked this conversation as resolved.
});

// 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: [
Expand Down
17 changes: 4 additions & 13 deletions examples/server/src/serverGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})
Comment thread
claude[bot] marked this conversation as resolved.
});
if (result.action === 'accept') {
return {
Expand Down
12 changes: 6 additions & 6 deletions packages/codemod/src/generated/versions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate.
export const V2_PACKAGE_VERSIONS: Record<string, string> = {
'@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'
};
2 changes: 2 additions & 0 deletions packages/core-internal/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export { getDisplayName } from '../../shared/metadataUtils';
export type {
BaseContext,
ClientContext,
ElicitInputFormParams,
ElicitInputResult,
NotificationOptions,
ProgressCallback,
ProtocolOptions,
Expand Down
22 changes: 20 additions & 2 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -203,6 +203,18 @@ export type BaseContext = {
};
};

export type ElicitInputFormParams<Schema extends StandardSchemaWithJSON = StandardSchemaWithJSON> = Omit<
ElicitRequestFormParams,
'requestedSchema'
> & {
requestedSchema: Schema;
Comment thread
claude[bot] marked this conversation as resolved.
};

export type ElicitInputResult<Schema extends StandardSchemaWithJSON> = Result & {
action: ElicitResult['action'];
content?: StandardSchemaWithJSON.InferOutput<Schema>;
};
Comment thread
claude[bot] marked this conversation as resolved.

/**
* Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields.
*/
Expand All @@ -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<ElicitResult>;
elicitInput: {
<Schema extends StandardSchemaWithJSON>(
params: ElicitInputFormParams<Schema>,
options?: RequestOptions
): Promise<ElicitInputResult<Schema>>;
(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise<ElicitResult>;
};

/**
* Request LLM sampling from the client.
Expand Down
142 changes: 142 additions & 0 deletions packages/server/src/server/elicitation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, ReadonlySet<string>> = 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<string, unknown>, parsed: Record<string, unknown>, 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;
}
Comment thread
mattzcarey marked this conversation as resolved.

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<StandardSchemaWithJSON>
): 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 };
}
Loading
Loading