fix: convert elicitation Standard Schemas via an explicit wire-grammar walk (#2454)

This commit is contained in:
Felix Weinberger 2026-07-09 12:30:57 +01:00 • committed by GitHub
commit 3834921074
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 494 additions and 36 deletions

View file

@ -3,4 +3,4 @@
'@modelcontextprotocol/server': minor
---
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.
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`.

View file

@ -39,6 +39,8 @@ server.registerTool(
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.
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.
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:
```

View file

@ -1,14 +1,24 @@
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 {
BooleanSchemaSchema,
ElicitRequestFormParamsSchema,
LegacyTitledEnumSchemaSchema,
NumberSchemaSchema,
PrimitiveSchemaDefinitionSchema,
StringSchemaSchema,
TitledMultiSelectEnumSchemaSchema,
TitledSingleSelectEnumSchemaSchema,
UntitledMultiSelectEnumSchemaSchema,
UntitledSingleSelectEnumSchemaSchema
} from '../types/schemas';
import type { ElicitRequestFormParams, StringSchema } from '../types/types';
import { parseSchema, shapeKeys } from '../util/schema';
import type { StandardSchemaWithJSON } from '../util/standardSchema';
import { isStandardSchemaWithJSON, standardSchemaToJsonSchema } from '../util/standardSchema';
import { isLibraryFormatPattern, isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema';
/** Input accepted by `inputRequired.elicit()`. */
export type ElicitInputParams = Omit<ElicitRequestFormParams, 'mode' | 'requestedSchema'> & {
mode?: 'form';
/** Input accepted by `inputRequired.elicit()`: a wire-ready elicitation JSON Schema or a Standard Schema. */
export type ElicitInputParams = Omit<ElicitRequestFormParams, 'requestedSchema'> & {
requestedSchema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON;
};
@ -28,58 +38,164 @@ function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Recor
}
}
const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', 'writeOnly']);
// JSON Schema metadata-vocabulary keys: positions that cannot carry them drop them silently.
const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set([
'$comment',
'deprecated',
'description',
'examples',
'readOnly',
'title',
'writeOnly'
]);
function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean {
return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-');
}
/**
* Finds converted keywords that MCP's restricted elicitation schema removed.
* Annotation-only metadata may be dropped; validation constraints may not be
* weakened silently.
*/
function findStrippedConstraintPaths(original: unknown, parsed: unknown, path = ''): string[] {
if (Array.isArray(original) && Array.isArray(parsed)) {
return original.flatMap((item, index) => findStrippedConstraintPaths(item, parsed[index], `${path}[${index}]`));
// The wire grammar, derived from the wire schemas so it tracks spec revisions. `$schema`
// is spec-declared on the root but reaches the wire type via its catchall.
const ROOT_KEYS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]);
const PROPERTY_KEYS_BY_TYPE: Record<string, ReadonlySet<string>> = {
string: shapeKeys([
StringSchemaSchema,
UntitledSingleSelectEnumSchemaSchema,
TitledSingleSelectEnumSchemaSchema,
LegacyTitledEnumSchemaSchema
]),
number: shapeKeys([NumberSchemaSchema]),
integer: shapeKeys([NumberSchemaSchema]),
boolean: shapeKeys([BooleanSchemaSchema]),
array: shapeKeys([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema])
};
const SUPPORTED_STRING_FORMATS: ReadonlySet<string> = new Set(StringSchemaSchema.shape.format.unwrap().options);
/** Walks one property node: keeps grammar keys, drops the library format pattern, rejects unknown constraints. */
function walkProperty(node: unknown, path: string, vendor: string, unsupported: string[]): unknown {
if (!isJsonObject(node)) {
return node;
}
// Object.hasOwn: a `type` like 'constructor' must not resolve through the prototype chain.
const allowedKeys =
typeof node.type === 'string' && Object.hasOwn(PROPERTY_KEYS_BY_TYPE, node.type) ? PROPERTY_KEYS_BY_TYPE[node.type] : undefined;
if (allowedKeys === undefined) {
// Unknown `type` — value validation rejects the node and names it.
return node;
}
const pruned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node)) {
if (allowedKeys.has(key) || isAnnotationOnlyJsonSchemaKeyword(key)) {
pruned[key] = value;
} else if (key === 'pattern' && node.type === 'string' && typeof node.format === 'string') {
if (!SUPPORTED_STRING_FORMATS.has(node.format)) {
pruned[key] = value; // the unsupported format itself fails value validation
} else if (
typeof value !== 'string' ||
!isLibraryFormatPattern(node.format as NonNullable<StringSchema['format']>, value, vendor)
) {
// A customized pattern must not be silently weakened.
unsupported.push(`${path}.${key}`);
}
} else {
unsupported.push(`${path}.${key}`);
}
}
return pruned;
}
/** Walks the schema root: keeps the spec root keys, drops annotations, rejects the rest. */
function walkRequestedSchema(converted: Record<string, unknown>, vendor: string): Record<string, unknown> {
const pruned: Record<string, unknown> = {};
const unsupported: string[] = [];
for (const [key, value] of Object.entries(converted)) {
if (key === 'properties' && isJsonObject(value)) {
pruned[key] = Object.fromEntries(
Object.entries(value).map(([name, node]) => [name, walkProperty(node, `properties.${name}`, vendor, unsupported)])
);
} else if (ROOT_KEYS.has(key)) {
pruned[key] = value;
} else if (!isAnnotationOnlyJsonSchemaKeyword(key)) {
unsupported.push(key);
}
}
if (unsupported.length > 0) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${unsupported.join(', ')}`
);
}
return pruned;
}
/** Names the properties that fail value validation, instead of surfacing a raw union dump. */
function describeUnsupportedProperties(pruned: Record<string, unknown>, fallback: string): string {
if (!isJsonObject(pruned.properties)) {
return fallback;
}
const offenders = Object.entries(pruned.properties)
.filter(([, node]) => !parseSchema(PrimitiveSchemaDefinitionSchema, node).success)
.map(([name]) => `properties.${name}`);
return offenders.length > 0 ? offenders.join(', ') : fallback;
}
// Safety net: value validation strips key combinations no single wire shape carries
// (e.g. `format` beside `enum`); a dropped non-annotation key must reject.
function findDroppedConstraintPaths(original: unknown, parsed: unknown, path = ''): string[] {
if (Array.isArray(original) && Array.isArray(parsed)) {
return original.flatMap((item, index) => findDroppedConstraintPaths(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)) {
return isAnnotationOnlyJsonSchemaKeyword(key) ? [] : [childPath];
}
return findStrippedConstraintPaths(value, parsed[key], childPath);
return findDroppedConstraintPaths(value, parsed[key], childPath);
});
}
/** Converts an authoring-friendly elicitation input into its wire-ready form. */
export function normalizeElicitInputParams(input: ElicitInputParams): ElicitRequestFormParams {
if (!isStandardSchemaWithJSON(input.requestedSchema)) {
// Route on `~standard.validate`: the converter owns the per-vendor fallback (zod
// 4.0/4.1 has no `~standard.jsonSchema`) — same decision as normalizeRawShapeSchema.
if (!isStandardSchema(input.requestedSchema)) {
return { ...input, mode: 'form', requestedSchema: input.requestedSchema };
}
const convertedSchema = convertStandardElicitationSchema(input.requestedSchema);
const normalized = { ...input, mode: 'form' as const, requestedSchema: convertedSchema };
const parsed = parseSchema(ElicitRequestFormParamsSchema, normalized);
const vendor = input.requestedSchema['~standard'].vendor;
const pruned = walkRequestedSchema(convertStandardElicitationSchema(input.requestedSchema), vendor);
// Scoped to the converted schema so params-level fields behave as on the raw branch.
const parsed = parseSchema(ElicitRequestFormParamsSchema.shape.requestedSchema, pruned);
if (!parsed.success) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsed.error.message}`
`Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${describeUnsupportedProperties(pruned, parsed.error.message)}`
);
}
const strippedConstraints = findStrippedConstraintPaths(convertedSchema, parsed.data.requestedSchema);
if (strippedConstraints.length > 0) {
const droppedConstraints = findDroppedConstraintPaths(pruned, parsed.data);
if (droppedConstraints.length > 0) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${strippedConstraints.join(', ')}`
`Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${droppedConstraints.join(', ')}`
);
}
return parsed.data;
// Converters can lose exotic property names from `properties` while keeping them in
// `required` (zod's toJSONSchema does for `__proto__`).
const danglingRequired = (parsed.data.required ?? []).filter(key => !Object.prototype.hasOwnProperty.call(parsed.data.properties, key));
if (danglingRequired.length > 0) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema lists required properties that are not defined in properties: ${danglingRequired.join(', ')}`
);
}
return { ...input, mode: 'form', requestedSchema: parsed.data };
}

View file

@ -61,7 +61,14 @@ interface InputRequiredBuilder {
*/
(spec: InputRequiredSpec): InputRequiredResult;
/** Builds an embedded form-mode elicitation request (`elicitation/create`). */
/**
* Builds an embedded form-mode elicitation request (`elicitation/create`).
*
* A Standard Schema `requestedSchema` is converted to the restricted wire shape;
* shapes it cannot express throw a `TypeError` before anything is sent. Responses
* are not validated against it — pass the same schema to `acceptedContent()` on
* re-entry for validated, typed content.
*/
elicit(params: ElicitInputParams): InputRequest;
/**

View file

@ -30,3 +30,10 @@ export function parseSchema<T extends AnySchema>(
): { success: true; data: z.output<T> } | { success: false; error: z.core.$ZodError } {
return z.safeParse(schema, data);
}
/**
* Union of the declared shape keys across several Zod object schemas.
*/
export function shapeKeys(schemas: Array<{ shape: Record<string, unknown> }>): ReadonlySet<string> {
return new Set(schemas.flatMap(schema => Object.keys(schema.shape)));
}

View file

@ -8,6 +8,8 @@
import * as z from 'zod/v4';
import type { StringSchema } from '../types/types';
// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)
export interface StandardTypedV1<Input = unknown, Output = Input> {
@ -164,6 +166,9 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch
let warnedZodFallback = false;
/** JSON Schema draft targeted by every conversion; shared so pattern references above stay in lockstep. */
export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
/**
* Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema.
*
@ -179,7 +184,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
const std = schema['~standard'];
let result: Record<string, unknown>;
if (std.jsonSchema) {
result = std.jsonSchema[io]({ target: 'draft-2020-12' });
result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET });
} else if (std.vendor === 'zod') {
// zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`).
// 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
'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.'
);
}
result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record<string, unknown>;
result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io }) as Record<string, unknown>;
} else {
throw new Error(
`Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` +
@ -275,6 +280,66 @@ export async function validateStandardSchema<T extends StandardSchemaV1>(
return { success: true, data: (result as StandardSchemaV1.SuccessResult<unknown>).value as StandardSchemaV1.InferOutput<T> };
}
/*
* Format-companion patterns: libraries realize a string `format` check as a companion
* `pattern` regex, which the elicitation wire schema cannot carry. zod's are derived
* from the resolved zod at runtime (never vendored — in-range releases change them), so
* customized zod patterns are distinguishable and reject; other vendors' realizations
* are unknowable (e.g. ArkType's `string.email`), so their patterns are trusted-and-dropped.
*/
function zodEmittedPattern(schema: z.ZodType): string | undefined {
const jsonSchema = z.toJSONSchema(schema, { target: JSON_SCHEMA_CONVERSION_TARGET, io: 'input' }) as Record<string, unknown>;
return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined;
}
const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/;
function datetimeReferenceSchemas(pattern: string): z.ZodType[] {
// Options (offset/local/precision) vary the emission; recovering the fraction-digit
// count keeps the candidate set finite.
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 })))
);
}
// Exhaustive over the wire's format enum: a new spec format is a compile error here.
function referencePatternsForFormat(format: NonNullable<StringSchema['format']>, 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;
}
}
return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined));
}
/** Whether `pattern` is the library's own realization of `format` (droppable) rather than a user customization. */
export function isLibraryFormatPattern(format: NonNullable<StringSchema['format']>, pattern: string, vendor: string): boolean {
if (vendor !== 'zod') {
return true;
}
return referencePatternsForFormat(format, pattern).has(pattern);
}
// Prompt argument extraction
export function promptArgumentsFromStandardSchema(

View file

@ -56,7 +56,7 @@ describe('inputRequired() builder', () => {
const request = inputRequired.elicit({
message: 'Registration details?',
requestedSchema: z.object({
email: z.string().meta({ title: 'Email', format: 'email' }),
email: z.email().meta({ title: 'Email' }),
count: z.number().min(1).max(5),
role: z.enum(['admin', 'member'])
})
@ -126,12 +126,13 @@ describe('inputRequired() builder', () => {
});
test('elicit rejects validation constraints the restricted wire schema cannot express', () => {
expect(() =>
const rejectPattern = () =>
inputRequired.elicit({
message: 'Code?',
requestedSchema: z.object({ code: z.string().regex(/^[A-Z]{3}$/) })
})
).toThrow(/properties\.code\.pattern/);
});
expect(rejectPattern).toThrow(TypeError);
expect(rejectPattern).toThrow(/properties\.code\.pattern/);
expect(() =>
inputRequired.elicit({
@ -139,6 +140,229 @@ describe('inputRequired() builder', () => {
requestedSchema: z.object({ address: z.object({ city: z.string() }) })
})
).toThrow(/flat primitive properties/);
// A customized pattern layered on a format cannot ride the wire; silently sending
// `format` alone would weaken the advertised constraint, so it rejects.
expect(() =>
inputRequired.elicit({
message: 'Email?',
requestedSchema: z.object({ email: z.email({ pattern: /@corp\.com$/ }) })
})
).toThrow(/properties\.email\.pattern/);
// Literal unions are the idiomatic zod spelling of an enum, but they convert to
// `anyOf`/`const`, which matches no wire enum variant — pinned so a change here
// surfaces in CI rather than user reports. (`z.literal(['a', 'b'])` and `z.enum`
// both convert fine.)
expect(() =>
inputRequired.elicit({
message: 'Role?',
requestedSchema: z.object({ role: z.union([z.literal('admin'), z.literal('member')]) })
})
).toThrow(TypeError);
});
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, precision: 3 })', z.iso.datetime({ offset: true, precision: 3 }), 'date-time'],
['z.string().meta({ format: "email" }) (annotation-only format)', z.string().meta({ format: 'email' }), 'email']
])('elicit keeps the %s format and drops the zod-emitted pattern', (_label, fieldSchema, format) => {
const request = inputRequired.elicit({
message: 'Value?',
requestedSchema: z.object({ value: fieldSchema })
});
const valueSchema = (request.params as { requestedSchema: { properties: Record<string, Record<string, unknown>> } }).requestedSchema
.properties.value!;
expect(valueSchema.format).toBe(format);
expect(valueSchema.pattern).toBeUndefined();
});
test('elicit keeps multi-select enums, optional fields, and defaults, and drops annotations the wire cannot place', () => {
const request = inputRequired.elicit({
message: 'Preferences?',
requestedSchema: z.object({
roles: z.array(z.enum(['admin', 'member']).meta({ title: 'Role' })),
plan: z.literal(['free', 'pro']),
nickname: z.string().optional(),
newsletter: z.boolean().default(false)
})
});
expect((request.params as { requestedSchema: unknown }).requestedSchema).toEqual({
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
roles: { type: 'array', items: { type: 'string', enum: ['admin', 'member'] } },
plan: { type: 'string', enum: ['free', 'pro'] },
nickname: { type: 'string' },
newsletter: { type: 'boolean', default: false }
},
required: ['roles', 'plan']
});
});
test("elicit trusts a non-zod vendor's format-companion pattern and drops it", () => {
// ArkType's `string.email` bakes in its own format regex; without vendor
// knowledge a companion pattern beside the retained format is the library's
// realization of it, not a customization.
const arkTypeLike = {
'~standard': {
version: 1,
vendor: 'arktype',
validate: (value: unknown) => ({ value }),
jsonSchema: {
input: () => ({
type: 'object',
properties: {
email: { type: 'string', format: 'email', pattern: String.raw`^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$` }
},
required: ['email']
}),
output: () => ({})
}
}
};
const request = inputRequired.elicit({ message: 'Email?', requestedSchema: arkTypeLike as never });
const emailSchema = (request.params as { requestedSchema: { properties: Record<string, Record<string, unknown>> } }).requestedSchema
.properties.email!;
expect(emailSchema.format).toBe('email');
expect(emailSchema.pattern).toBeUndefined();
});
test('elicit rejects schemas whose converted required entries have no matching property', () => {
// zod's toJSONSchema loses a `__proto__` property from `properties` while still
// listing it in `required` — reject rather than ship the corrupt schema.
const act = () =>
inputRequired.elicit({
message: 'Name?',
requestedSchema: z.object({ ['__proto__']: z.string() })
});
expect(act).toThrow(TypeError);
expect(act).toThrow(/required properties that are not defined/);
});
test('elicit keeps unknown params extension keys on both branches', () => {
const rawRequest = inputRequired.elicit({
message: 'OK?',
requestedSchema: { type: 'object', properties: {} },
myExtension: 'keep-me'
} as never);
const convertedRequest = inputRequired.elicit({
message: 'OK?',
requestedSchema: z.object({}),
myExtension: 'keep-me'
} as never);
expect((rawRequest.params as { myExtension?: string }).myExtension).toBe('keep-me');
expect((convertedRequest.params as { myExtension?: string }).myExtension).toBe('keep-me');
});
test('elicit rejects root keywords outside the spec requestedSchema shape', () => {
const act = () =>
inputRequired.elicit({
message: 'Name?',
requestedSchema: z.strictObject({ name: z.string() })
});
expect(act).toThrow(TypeError);
expect(act).toThrow(/unsupported JSON Schema constraint\(s\).*additionalProperties/);
expect(() =>
inputRequired.elicit({
message: 'Name?',
requestedSchema: {
'~standard': {
version: 1,
vendor: 'test',
validate: (value: unknown) => ({ value }),
jsonSchema: {
input: () => ({
$defs: { Name: { type: 'string' } },
type: 'object',
properties: { name: { $ref: '#/$defs/Name' } },
required: ['name']
}),
output: () => ({})
}
}
} as never
})
).toThrow(/\$defs/);
});
test('elicit drops annotation-only root keywords', () => {
const request = inputRequired.elicit({
message: 'Name?',
requestedSchema: z.object({ name: z.string() }).meta({ title: 'User', description: 'User info' })
});
expect((request.params as { requestedSchema: unknown }).requestedSchema).toEqual({
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
});
});
test('elicit 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 request = inputRequired.elicit({ message: 'Name?', requestedSchema: functionSchema as never });
expect((request.params as { requestedSchema: unknown }).requestedSchema).toEqual(jsonSchema);
});
test('elicit converts validate-only schemas via the converter rather than the raw branch', () => {
// Simulates zod before 4.2 (validate without `~standard.jsonSchema`): routing must
// go to the converter, which owns per-vendor handling — a non-zod vendor without
// jsonSchema fails closed there instead of shipping the schema object on the wire.
const validateOnly = {
'~standard': {
version: 1,
vendor: 'not-zod',
validate: (value: unknown) => ({ value })
}
};
const act = () => inputRequired.elicit({ message: 'Name?', requestedSchema: validateOnly as never });
expect(act).toThrow(TypeError);
expect(act).toThrow(/does not implement StandardJSONSchemaV1/);
});
test('elicit passes a plain JSON schema containing a literal "~standard" key through the raw branch', () => {
const rawSchema = {
type: 'object' as const,
properties: { name: { type: 'string' as const } },
'~standard': 'extension-data'
};
const request = inputRequired.elicit({ message: 'Name?', requestedSchema: rawSchema as never });
expect((request.params as { requestedSchema: unknown }).requestedSchema).toBe(rawSchema);
});
test('elicit rejects a property type shadowing an Object.prototype member with the normal message', () => {
const schema = {
'~standard': {
version: 1,
vendor: 'test',
validate: (value: unknown) => ({ value }),
jsonSchema: {
input: () => ({ type: 'object', properties: { x: { type: 'constructor' } }, required: ['x'] }),
output: () => ({})
}
}
};
expect(() => inputRequired.elicit({ message: 'x', requestedSchema: schema as never })).toThrow(
/flat primitive properties.*properties\.x/
);
});
});

View file

@ -6,7 +6,7 @@
import { Client } from '@modelcontextprotocol/client';
import type { TextContent } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
import { completable, fromJsonSchema as serverFromJsonSchema, McpServer } from '@modelcontextprotocol/server';
import { completable, fromJsonSchema as serverFromJsonSchema, inputRequired, McpServer } from '@modelcontextprotocol/server';
import { toStandardJsonSchema } from '@valibot/to-json-schema';
import { type } from 'arktype';
import * as v from 'valibot';
@ -714,3 +714,40 @@ describe('Standard Schema Support', () => {
});
});
});
describe('Standard Schema elicitation conversion', () => {
test('converts ArkType format schemas, dropping the library format-companion pattern', () => {
const schema = type({ email: 'string.email' });
const request = inputRequired.elicit({ message: 'Email?', requestedSchema: schema });
const emailSchema = (request.params as { requestedSchema: { properties: Record<string, Record<string, unknown>> } }).requestedSchema
.properties.email!;
expect(emailSchema.type).toBe('string');
expect(emailSchema.format).toBe('email');
expect(emailSchema.pattern).toBeUndefined();
});
test('converts Valibot schemas via toStandardJsonSchema', () => {
const schema = toStandardJsonSchema(
v.object({
email: v.pipe(v.string(), v.email()),
count: v.number()
})
);
const request = inputRequired.elicit({ message: 'Details?', requestedSchema: schema });
const requestedSchema = (request.params as { requestedSchema: { properties: Record<string, Record<string, unknown>> } })
.requestedSchema;
expect(requestedSchema.properties.email!.format).toBe('email');
expect(requestedSchema.properties.email!.pattern).toBeUndefined();
expect(requestedSchema.properties.count!.type).toBe('number');
});
test('rejects ArkType schemas the restricted wire schema cannot express', () => {
const nested = type({ address: { city: 'string' } });
expect(() => inputRequired.elicit({ message: 'Address?', requestedSchema: nested })).toThrow(TypeError);
});
});