Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/standard-schema-elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
---

Allow form elicitation requests to accept Standard Schema values such as Zod objects for `requestedSchema`. The server converts these schemas to MCP's restricted elicitation JSON Schema before sending and parses accepted content with the original schema before returning typed
results. Zod string formats that map to MCP's supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary regex patterns remain rejected because form elicitation does not carry JSON Schema `pattern`.
results. Zod string formats that map to MCP's supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary regex patterns remain rejected because form elicitation does not carry JSON Schema `pattern`. The converted schema's root is held to the spec's shape (`type`, `properties`, `required`, `$schema`): unknown root keywords — such as the `additionalProperties` emitted by `z.strictObject()` — are rejected before sending, and annotation-only root keywords (`title`, `description`) are dropped.
143 changes: 95 additions & 48 deletions packages/core-internal/src/shared/elicitation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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 { standardSchemaToJsonSchema } from '../util/standardSchema';
import { isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema';
import type { ElicitInputFormParams } from './protocol';

export type NormalizedElicitInputFormParams = {
Expand All @@ -16,52 +18,66 @@ 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}`);
// 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<string, unknown>;
return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined;
}

const ZOD_PRECISION_TIME_PATTERN = new RegExp(String.raw`^${escapeRegExpLiteral(String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d\.\d{`)}\d+\}$`);
const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/;

function isZodIsoDatetimePattern(pattern: string): boolean {
const prefix = `^${ZOD_ISO_DATE_PATTERN}T(?:`;
if (!pattern.startsWith(prefix) || !pattern.endsWith(')$')) {
return false;
function datetimeReferenceSchemas(pattern: string): z.ZodType[] {
// The emitted pattern depends on the authoring options (offset/local/precision); the
// fraction-digit count recovered from the pattern under test keeps the candidate set
// finite. Duplicate candidates are fine — the result feeds a Set.
const fractionDigits = DATETIME_FRACTION_DIGITS.exec(pattern);
const precisions: Array<number | undefined> = [undefined, -1, 0];
if (fractionDigits) {
precisions.push(Number(fractionDigits[1]));
}
return [false, true].flatMap(local =>
[false, true].flatMap(offset => precisions.map(precision => z.iso.datetime({ local, offset, precision })))
);
}

const innerPattern = pattern.slice(prefix.length, -2);
const zoneSuffix = ZOD_DATETIME_ZONE_SUFFIXES.find(suffix => innerPattern.endsWith(suffix));
if (!zoneSuffix) {
return false;
function referencePatternsForFormat(format: string, pattern: string): ReadonlySet<string> {
let referenceSchemas: z.ZodType[];
switch (format) {
case 'email': {
referenceSchemas = [z.email()];
break;
}
case 'uri': {
referenceSchemas = [z.url()];
break;
}
case 'date': {
referenceSchemas = [z.iso.date()];
break;
}
case 'date-time': {
referenceSchemas = datetimeReferenceSchemas(pattern);
break;
}
default: {
referenceSchemas = [];
}
}

const timePattern = innerPattern.slice(0, -zoneSuffix.length);
return (
timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}` ||
timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d` ||
timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}(?::[0-5]\d(?:\.\d+)?)?` ||
ZOD_PRECISION_TIME_PATTERN.test(timePattern)
);
return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined));
}

function isRedundantFormatPattern(original: Record<string, unknown>, parsed: Record<string, unknown>, key: string): boolean {
Expand All @@ -75,11 +91,7 @@ function isRedundantFormatPattern(original: Record<string, unknown>, parsed: Rec
return false;
}

if (parsed.format === 'date-time') {
return isZodIsoDatetimePattern(original.pattern);
}

return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true;
return referencePatternsForFormat(parsed.format, original.pattern).has(original.pattern);
}

const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', 'writeOnly']);
Expand All @@ -88,6 +100,36 @@ function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean {
return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-');
}

// The spec declares a closed shape for the `requestedSchema` root: `$schema`, `type`,
// `properties` and `required` only. The wire schema cannot enforce that (its root is a
// catchall so hand-authored extensions stay wire-legal), and the stripped-keys diff below
// never fires for root keys the catchall retains — so converted Standard Schemas are pruned
// here: annotation-only root keywords are dropped, anything else unknown rejects (e.g.
// `z.strictObject()` emits a root `additionalProperties: false`). The keyword set is derived
// from the wire schema so it tracks spec revisions; `$schema` is spec-declared but absent
// from the wire schema's declared keys (the catchall admits it).
const ELICITATION_ROOT_KEYWORDS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]);
const ROOT_ANNOTATION_KEYWORDS = new Set(['title', 'description']);

function pruneElicitationSchemaRoot(schema: Record<string, unknown>): Record<string, unknown> {
const requestedSchema: Record<string, unknown> = {};
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}]`));
Expand All @@ -112,7 +154,12 @@ function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path =
function isElicitInputSchema(
schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON
): schema is StandardSchemaWithJSON {
return typeof schema === 'object' && schema !== null && '~standard' in schema;
// isStandardSchema, not a plain '~standard' key probe: ArkType schemas are functions
// (a typeof-object check routes them, unconverted, to the raw branch), and a plain JSON
// schema containing a literal '~standard' key — wire-legal via the catchall — must stay
// on the raw branch. Not isStandardSchemaWithJSON: gating on `~standard.jsonSchema` here
// would front-run standardSchemaToJsonSchema's zod 4.0/4.1 fallback (see zodCompat.ts).
return isStandardSchema(schema);
}

function convertStandardElicitationSchema(standardSchema: StandardSchemaWithJSON): Record<string, unknown> {
Expand All @@ -137,7 +184,7 @@ export function normalizeElicitInputFormParams(
const standardSchema = formParams.requestedSchema;
const normalizedParams = {
...formParams,
requestedSchema: convertStandardElicitationSchema(standardSchema)
requestedSchema: pruneElicitationSchemaRoot(convertStandardElicitationSchema(standardSchema))
};
const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams);
if (!parsedParams.success) {
Expand Down
16 changes: 12 additions & 4 deletions packages/core-internal/src/shared/inputRequired.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -128,10 +129,17 @@ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequi
| (Omit<ElicitRequestFormParams, 'mode'> & { mode?: 'form' })
| (Omit<ElicitInputFormParams<StandardSchemaWithJSON>, 'mode'> & { mode?: 'form' })
): InputRequest {
const { params: normalizedParams } = normalizeElicitInputFormParams(
params as ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
);
return { method: 'elicitation/create', params: normalizedParams };
try {
const { params: normalizedParams } = normalizeElicitInputFormParams(
params as ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
);
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<ElicitRequestURLParams, 'mode' | 'elicitationId'>): InputRequest {
// The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the
Expand Down
Loading
Loading