Skip to content

Commit 3834921

Browse files
fix: convert elicitation Standard Schemas via an explicit wire-grammar walk (#2454)
1 parent 24be404 commit 3834921

8 files changed

Lines changed: 494 additions & 36 deletions

File tree

.changeset/standard-schema-elicitation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
'@modelcontextprotocol/server': minor
44
---
55

6-
Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry.
6+
Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry. Zod formats mapping to `email`, `uri`, `date`, and `date-time` are supported. Shapes the restricted schema cannot express reject before anything is sent — nested objects, `.regex()` and customized zod format patterns, exclusive number bounds (`.positive()`/`.gt()`), literal unions (use `z.enum` or `z.literal(['a', 'b'])`), and non-spec root keywords like `z.strictObject()`'s `additionalProperties`.

docs/servers/input-required.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ server.registerTool(
3939

4040
The first round converts `confirmationSchema` to MCP's restricted elicitation JSON Schema and returns it inside `resultType: 'input_required'`. The client fulfils the request and retries `deploy`; on re-entry `acceptedContent` validates the answer with that same schema and the handler finishes.
4141

42+
The restricted wire schema is a flat object of primitive properties, so only schemas that convert to that shape are accepted: strings (including the `email`, `uri`, `date`, and `date-time` formats — `z.email()`, `z.iso.date()`, and friends), numbers and their inclusive bounds (`.min()`/`.max()`; exclusive bounds like `.positive()` or `.gt()` do not convert), booleans, enums (`z.enum` or `z.literal(['a', 'b'])` — a union of literals does not convert), multi-select enum arrays, `.optional()`, and `.default()`. Anything the wire cannot express — nested objects, `.regex()` patterns, customized zod format patterns (`z.email({ pattern })`) — throws a `TypeError` when the request is built, before anything is sent. For non-zod libraries a pattern accompanying a supported format is treated as the library's own format regex and dropped from the wire. Constraints the wire cannot advertise at all (refinements, transforms) still hold on re-entry, because `acceptedContent` validates with the original schema.
43+
4244
Every call on this page comes from an in-memory `Client` with an `elicitation/create` handler — [Test a server](../testing.md) shows that wiring. Calling `deploy` once produces both rounds:
4345

4446
```
Lines changed: 143 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
import { ProtocolErrorCode } from '../types/enums';
22
import { ProtocolError } from '../types/errors';
3-
import { ElicitRequestFormParamsSchema } from '../types/schemas';
4-
import type { ElicitRequestFormParams } from '../types/types';
5-
import { parseSchema } from '../util/schema';
3+
import {
4+
BooleanSchemaSchema,
5+
ElicitRequestFormParamsSchema,
6+
LegacyTitledEnumSchemaSchema,
7+
NumberSchemaSchema,
8+
PrimitiveSchemaDefinitionSchema,
9+
StringSchemaSchema,
10+
TitledMultiSelectEnumSchemaSchema,
11+
TitledSingleSelectEnumSchemaSchema,
12+
UntitledMultiSelectEnumSchemaSchema,
13+
UntitledSingleSelectEnumSchemaSchema
14+
} from '../types/schemas';
15+
import type { ElicitRequestFormParams, StringSchema } from '../types/types';
16+
import { parseSchema, shapeKeys } from '../util/schema';
617
import type { StandardSchemaWithJSON } from '../util/standardSchema';
7-
import { isStandardSchemaWithJSON, standardSchemaToJsonSchema } from '../util/standardSchema';
18+
import { isLibraryFormatPattern, isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema';
819

9-
/** Input accepted by `inputRequired.elicit()`. */
10-
export type ElicitInputParams = Omit<ElicitRequestFormParams, 'mode' | 'requestedSchema'> & {
11-
mode?: 'form';
20+
/** Input accepted by `inputRequired.elicit()`: a wire-ready elicitation JSON Schema or a Standard Schema. */
21+
export type ElicitInputParams = Omit<ElicitRequestFormParams, 'requestedSchema'> & {
1222
requestedSchema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON;
1323
};
1424

@@ -28,58 +38,164 @@ function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Recor
2838
}
2939
}
3040

31-
const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', 'writeOnly']);
41+
// JSON Schema metadata-vocabulary keys: positions that cannot carry them drop them silently.
42+
const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set([
43+
'$comment',
44+
'deprecated',
45+
'description',
46+
'examples',
47+
'readOnly',
48+
'title',
49+
'writeOnly'
50+
]);
3251

3352
function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean {
3453
return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-');
3554
}
3655

37-
/**
38-
* Finds converted keywords that MCP's restricted elicitation schema removed.
39-
* Annotation-only metadata may be dropped; validation constraints may not be
40-
* weakened silently.
41-
*/
42-
function findStrippedConstraintPaths(original: unknown, parsed: unknown, path = ''): string[] {
43-
if (Array.isArray(original) && Array.isArray(parsed)) {
44-
return original.flatMap((item, index) => findStrippedConstraintPaths(item, parsed[index], `${path}[${index}]`));
56+
// The wire grammar, derived from the wire schemas so it tracks spec revisions. `$schema`
57+
// is spec-declared on the root but reaches the wire type via its catchall.
58+
const ROOT_KEYS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]);
59+
60+
const PROPERTY_KEYS_BY_TYPE: Record<string, ReadonlySet<string>> = {
61+
string: shapeKeys([
62+
StringSchemaSchema,
63+
UntitledSingleSelectEnumSchemaSchema,
64+
TitledSingleSelectEnumSchemaSchema,
65+
LegacyTitledEnumSchemaSchema
66+
]),
67+
number: shapeKeys([NumberSchemaSchema]),
68+
integer: shapeKeys([NumberSchemaSchema]),
69+
boolean: shapeKeys([BooleanSchemaSchema]),
70+
array: shapeKeys([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema])
71+
};
72+
73+
const SUPPORTED_STRING_FORMATS: ReadonlySet<string> = new Set(StringSchemaSchema.shape.format.unwrap().options);
74+
75+
/** Walks one property node: keeps grammar keys, drops the library format pattern, rejects unknown constraints. */
76+
function walkProperty(node: unknown, path: string, vendor: string, unsupported: string[]): unknown {
77+
if (!isJsonObject(node)) {
78+
return node;
79+
}
80+
// Object.hasOwn: a `type` like 'constructor' must not resolve through the prototype chain.
81+
const allowedKeys =
82+
typeof node.type === 'string' && Object.hasOwn(PROPERTY_KEYS_BY_TYPE, node.type) ? PROPERTY_KEYS_BY_TYPE[node.type] : undefined;
83+
if (allowedKeys === undefined) {
84+
// Unknown `type` — value validation rejects the node and names it.
85+
return node;
86+
}
87+
88+
const pruned: Record<string, unknown> = {};
89+
for (const [key, value] of Object.entries(node)) {
90+
if (allowedKeys.has(key) || isAnnotationOnlyJsonSchemaKeyword(key)) {
91+
pruned[key] = value;
92+
} else if (key === 'pattern' && node.type === 'string' && typeof node.format === 'string') {
93+
if (!SUPPORTED_STRING_FORMATS.has(node.format)) {
94+
pruned[key] = value; // the unsupported format itself fails value validation
95+
} else if (
96+
typeof value !== 'string' ||
97+
!isLibraryFormatPattern(node.format as NonNullable<StringSchema['format']>, value, vendor)
98+
) {
99+
// A customized pattern must not be silently weakened.
100+
unsupported.push(`${path}.${key}`);
101+
}
102+
} else {
103+
unsupported.push(`${path}.${key}`);
104+
}
105+
}
106+
return pruned;
107+
}
108+
109+
/** Walks the schema root: keeps the spec root keys, drops annotations, rejects the rest. */
110+
function walkRequestedSchema(converted: Record<string, unknown>, vendor: string): Record<string, unknown> {
111+
const pruned: Record<string, unknown> = {};
112+
const unsupported: string[] = [];
113+
for (const [key, value] of Object.entries(converted)) {
114+
if (key === 'properties' && isJsonObject(value)) {
115+
pruned[key] = Object.fromEntries(
116+
Object.entries(value).map(([name, node]) => [name, walkProperty(node, `properties.${name}`, vendor, unsupported)])
117+
);
118+
} else if (ROOT_KEYS.has(key)) {
119+
pruned[key] = value;
120+
} else if (!isAnnotationOnlyJsonSchemaKeyword(key)) {
121+
unsupported.push(key);
122+
}
123+
}
124+
if (unsupported.length > 0) {
125+
throw new ProtocolError(
126+
ProtocolErrorCode.InvalidParams,
127+
`Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${unsupported.join(', ')}`
128+
);
129+
}
130+
return pruned;
131+
}
132+
133+
/** Names the properties that fail value validation, instead of surfacing a raw union dump. */
134+
function describeUnsupportedProperties(pruned: Record<string, unknown>, fallback: string): string {
135+
if (!isJsonObject(pruned.properties)) {
136+
return fallback;
45137
}
138+
const offenders = Object.entries(pruned.properties)
139+
.filter(([, node]) => !parseSchema(PrimitiveSchemaDefinitionSchema, node).success)
140+
.map(([name]) => `properties.${name}`);
141+
return offenders.length > 0 ? offenders.join(', ') : fallback;
142+
}
46143

144+
// Safety net: value validation strips key combinations no single wire shape carries
145+
// (e.g. `format` beside `enum`); a dropped non-annotation key must reject.
146+
function findDroppedConstraintPaths(original: unknown, parsed: unknown, path = ''): string[] {
147+
if (Array.isArray(original) && Array.isArray(parsed)) {
148+
return original.flatMap((item, index) => findDroppedConstraintPaths(item, parsed[index], `${path}[${index}]`));
149+
}
47150
if (!isJsonObject(original) || !isJsonObject(parsed)) {
48151
return [];
49152
}
50-
51153
return Object.entries(original).flatMap(([key, value]) => {
52154
const childPath = path ? `${path}.${key}` : key;
53155
if (!Object.prototype.hasOwnProperty.call(parsed, key)) {
54156
return isAnnotationOnlyJsonSchemaKeyword(key) ? [] : [childPath];
55157
}
56-
return findStrippedConstraintPaths(value, parsed[key], childPath);
158+
return findDroppedConstraintPaths(value, parsed[key], childPath);
57159
});
58160
}
59161

60162
/** Converts an authoring-friendly elicitation input into its wire-ready form. */
61163
export function normalizeElicitInputParams(input: ElicitInputParams): ElicitRequestFormParams {
62-
if (!isStandardSchemaWithJSON(input.requestedSchema)) {
164+
// Route on `~standard.validate`: the converter owns the per-vendor fallback (zod
165+
// 4.0/4.1 has no `~standard.jsonSchema`) — same decision as normalizeRawShapeSchema.
166+
if (!isStandardSchema(input.requestedSchema)) {
63167
return { ...input, mode: 'form', requestedSchema: input.requestedSchema };
64168
}
65169

66-
const convertedSchema = convertStandardElicitationSchema(input.requestedSchema);
67-
const normalized = { ...input, mode: 'form' as const, requestedSchema: convertedSchema };
68-
const parsed = parseSchema(ElicitRequestFormParamsSchema, normalized);
170+
const vendor = input.requestedSchema['~standard'].vendor;
171+
const pruned = walkRequestedSchema(convertStandardElicitationSchema(input.requestedSchema), vendor);
172+
173+
// Scoped to the converted schema so params-level fields behave as on the raw branch.
174+
const parsed = parseSchema(ElicitRequestFormParamsSchema.shape.requestedSchema, pruned);
69175
if (!parsed.success) {
70176
throw new ProtocolError(
71177
ProtocolErrorCode.InvalidParams,
72-
`Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsed.error.message}`
178+
`Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${describeUnsupportedProperties(pruned, parsed.error.message)}`
179+
);
180+
}
181+
182+
const droppedConstraints = findDroppedConstraintPaths(pruned, parsed.data);
183+
if (droppedConstraints.length > 0) {
184+
throw new ProtocolError(
185+
ProtocolErrorCode.InvalidParams,
186+
`Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${droppedConstraints.join(', ')}`
73187
);
74188
}
75189

76-
const strippedConstraints = findStrippedConstraintPaths(convertedSchema, parsed.data.requestedSchema);
77-
if (strippedConstraints.length > 0) {
190+
// Converters can lose exotic property names from `properties` while keeping them in
191+
// `required` (zod's toJSONSchema does for `__proto__`).
192+
const danglingRequired = (parsed.data.required ?? []).filter(key => !Object.prototype.hasOwnProperty.call(parsed.data.properties, key));
193+
if (danglingRequired.length > 0) {
78194
throw new ProtocolError(
79195
ProtocolErrorCode.InvalidParams,
80-
`Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${strippedConstraints.join(', ')}`
196+
`Elicitation requestedSchema lists required properties that are not defined in properties: ${danglingRequired.join(', ')}`
81197
);
82198
}
83199

84-
return parsed.data;
200+
return { ...input, mode: 'form', requestedSchema: parsed.data };
85201
}

packages/core-internal/src/shared/inputRequired.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ interface InputRequiredBuilder {
6161
*/
6262
(spec: InputRequiredSpec): InputRequiredResult;
6363

64-
/** Builds an embedded form-mode elicitation request (`elicitation/create`). */
64+
/**
65+
* Builds an embedded form-mode elicitation request (`elicitation/create`).
66+
*
67+
* A Standard Schema `requestedSchema` is converted to the restricted wire shape;
68+
* shapes it cannot express throw a `TypeError` before anything is sent. Responses
69+
* are not validated against it — pass the same schema to `acceptedContent()` on
70+
* re-entry for validated, typed content.
71+
*/
6572
elicit(params: ElicitInputParams): InputRequest;
6673

6774
/**

packages/core-internal/src/util/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,10 @@ export function parseSchema<T extends AnySchema>(
3030
): { success: true; data: z.output<T> } | { success: false; error: z.core.$ZodError } {
3131
return z.safeParse(schema, data);
3232
}
33+
34+
/**
35+
* Union of the declared shape keys across several Zod object schemas.
36+
*/
37+
export function shapeKeys(schemas: Array<{ shape: Record<string, unknown> }>): ReadonlySet<string> {
38+
return new Set(schemas.flatMap(schema => Object.keys(schema.shape)));
39+
}

packages/core-internal/src/util/standardSchema.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import * as z from 'zod/v4';
1010

11+
import type { StringSchema } from '../types/types';
12+
1113
// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)
1214

1315
export interface StandardTypedV1<Input = unknown, Output = Input> {
@@ -164,6 +166,9 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch
164166

165167
let warnedZodFallback = false;
166168

169+
/** JSON Schema draft targeted by every conversion; shared so pattern references above stay in lockstep. */
170+
export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
171+
167172
/**
168173
* Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema.
169174
*
@@ -179,7 +184,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
179184
const std = schema['~standard'];
180185
let result: Record<string, unknown>;
181186
if (std.jsonSchema) {
182-
result = std.jsonSchema[io]({ target: 'draft-2020-12' });
187+
result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET });
183188
} else if (std.vendor === 'zod') {
184189
// zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`).
185190
// The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list.
@@ -198,7 +203,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
198203
'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.'
199204
);
200205
}
201-
result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record<string, unknown>;
206+
result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io }) as Record<string, unknown>;
202207
} else {
203208
throw new Error(
204209
`Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` +
@@ -275,6 +280,66 @@ export async function validateStandardSchema<T extends StandardSchemaV1>(
275280
return { success: true, data: (result as StandardSchemaV1.SuccessResult<unknown>).value as StandardSchemaV1.InferOutput<T> };
276281
}
277282

283+
/*
284+
* Format-companion patterns: libraries realize a string `format` check as a companion
285+
* `pattern` regex, which the elicitation wire schema cannot carry. zod's are derived
286+
* from the resolved zod at runtime (never vendored — in-range releases change them), so
287+
* customized zod patterns are distinguishable and reject; other vendors' realizations
288+
* are unknowable (e.g. ArkType's `string.email`), so their patterns are trusted-and-dropped.
289+
*/
290+
291+
function zodEmittedPattern(schema: z.ZodType): string | undefined {
292+
const jsonSchema = z.toJSONSchema(schema, { target: JSON_SCHEMA_CONVERSION_TARGET, io: 'input' }) as Record<string, unknown>;
293+
return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined;
294+
}
295+
296+
const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/;
297+
298+
function datetimeReferenceSchemas(pattern: string): z.ZodType[] {
299+
// Options (offset/local/precision) vary the emission; recovering the fraction-digit
300+
// count keeps the candidate set finite.
301+
const fractionDigits = DATETIME_FRACTION_DIGITS.exec(pattern);
302+
const precisions: Array<number | undefined> = [undefined, -1, 0];
303+
if (fractionDigits) {
304+
precisions.push(Number(fractionDigits[1]));
305+
}
306+
return [false, true].flatMap(local =>
307+
[false, true].flatMap(offset => precisions.map(precision => z.iso.datetime({ local, offset, precision })))
308+
);
309+
}
310+
311+
// Exhaustive over the wire's format enum: a new spec format is a compile error here.
312+
function referencePatternsForFormat(format: NonNullable<StringSchema['format']>, pattern: string): ReadonlySet<string> {
313+
let referenceSchemas: z.ZodType[];
314+
switch (format) {
315+
case 'email': {
316+
referenceSchemas = [z.email()];
317+
break;
318+
}
319+
case 'uri': {
320+
referenceSchemas = [z.url()];
321+
break;
322+
}
323+
case 'date': {
324+
referenceSchemas = [z.iso.date()];
325+
break;
326+
}
327+
case 'date-time': {
328+
referenceSchemas = datetimeReferenceSchemas(pattern);
329+
break;
330+
}
331+
}
332+
return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined));
333+
}
334+
335+
/** Whether `pattern` is the library's own realization of `format` (droppable) rather than a user customization. */
336+
export function isLibraryFormatPattern(format: NonNullable<StringSchema['format']>, pattern: string, vendor: string): boolean {
337+
if (vendor !== 'zod') {
338+
return true;
339+
}
340+
return referencePatternsForFormat(format, pattern).has(pattern);
341+
}
342+
278343
// Prompt argument extraction
279344

280345
export function promptArgumentsFromStandardSchema(

0 commit comments

Comments
 (0)