fix: convert elicitation Standard Schemas via an explicit wire-grammar walk (#2454)
This commit is contained in:
parent
24be4040d4
commit
3834921074
8 changed files with 494 additions and 36 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue