Skip to content

Commit a228f8a

Browse files
fix: harden Standard Schema elicitation conversion
- Reject unknown root-level requestedSchema keywords after conversion. The wire schema's root is a catchall, so keys like the additionalProperties emitted by z.strictObject() passed the stripped-keys gate onto the wire; the root is now held to the spec-declared shape (type/properties/required/$schema, derived from the wire schema), with annotation-only root keywords dropped. - Derive redundant format-pattern references from the installed zod at runtime instead of vendoring its regexes. The vendored literals were byte-coupled to one zod version against a ^4.2.0 peer range, so any in-range regex change would reject working schemas at user runtime while pinned CI stayed green. Customized patterns still reject. - Throw TypeError (with cause) from inputRequired.elicit on unsupported schemas, matching the builder's authoring-error convention; ProtocolError from a prompts/get handler would reach the client as InvalidParams on its own request.
1 parent 7908e11 commit a228f8a

6 files changed

Lines changed: 264 additions & 54 deletions

File tree

.changeset/standard-schema-elicitation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
---
66

77
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
8-
results. Zod string formats that map to MCP's supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary regex patterns remain rejected because form elicitation does not carry JSON Schema `pattern`.
8+
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.

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

Lines changed: 88 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as z from 'zod/v4';
2+
13
import { ProtocolErrorCode } from '../types/enums';
24
import { ProtocolError } from '../types/errors';
35
import { ElicitRequestFormParamsSchema } from '../types/schemas';
@@ -16,52 +18,66 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
1618
return typeof value === 'object' && value !== null && !Array.isArray(value);
1719
}
1820

19-
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])))`;
20-
const ZOD_ISO_TIME_PREFIX = String.raw`(?:[01]\d|2[0-3]):[0-5]\d`;
21-
const ZOD_ISO_OFFSET_PATTERN = String.raw`([+-](?:[01]\d|2[0-3]):[0-5]\d)`;
22-
23-
const ZOD_REDUNDANT_FORMAT_PATTERNS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
24-
['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,}$`])],
25-
[
26-
'date',
27-
new Set([
28-
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])))$`
29-
])
30-
]
31-
]);
32-
33-
const ZOD_DATETIME_ZONE_SUFFIXES = [
34-
String.raw`(?:Z)`,
35-
String.raw`(?:Z|)`,
36-
String.raw`(?:Z|${ZOD_ISO_OFFSET_PATTERN})`,
37-
String.raw`(?:Z||${ZOD_ISO_OFFSET_PATTERN})`
38-
] as const;
39-
40-
function escapeRegExpLiteral(value: string): string {
41-
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, match => `\\${match}`);
21+
// A `pattern` emitted beside a supported `format` is redundant — zod realizes every format
22+
// check as a regex — and is dropped so the wire schema stays within the elicitation subset.
23+
// The reference patterns are derived from the installed zod at runtime rather than vendored
24+
// as string literals: zod's format regexes change across in-range releases, so a vendored
25+
// copy would start rejecting schemas produced by any newer zod while CI (lockfile-pinned)
26+
// stays green. A pattern the installed zod would not emit for that format — e.g. a
27+
// customized `z.email({ pattern })` — still rejects, because the wire schema cannot carry it.
28+
// Residual limitation: if the app resolves a second zod copy whose regexes differ from this
29+
// package's resolved zod, its emissions won't match the reference and reject (fail closed);
30+
// zod is a peer dependency precisely so installs dedupe to one copy.
31+
// Derivation is cheap (a handful of toJSONSchema calls) and only runs when a stripped
32+
// `pattern` sits beside a matching `format`, so no caching.
33+
34+
function zodEmittedPattern(schema: z.ZodType): string | undefined {
35+
// Conversion options must stay in lockstep with standardSchemaToJsonSchema (which
36+
// produced the pattern under comparison via `~standard.jsonSchema.input`).
37+
const jsonSchema = z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' }) as Record<string, unknown>;
38+
return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined;
4239
}
4340

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

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

52-
const innerPattern = pattern.slice(prefix.length, -2);
53-
const zoneSuffix = ZOD_DATETIME_ZONE_SUFFIXES.find(suffix => innerPattern.endsWith(suffix));
54-
if (!zoneSuffix) {
55-
return false;
57+
function referencePatternsForFormat(format: string, pattern: string): ReadonlySet<string> {
58+
let referenceSchemas: z.ZodType[];
59+
switch (format) {
60+
case 'email': {
61+
referenceSchemas = [z.email()];
62+
break;
63+
}
64+
case 'uri': {
65+
referenceSchemas = [z.url()];
66+
break;
67+
}
68+
case 'date': {
69+
referenceSchemas = [z.iso.date()];
70+
break;
71+
}
72+
case 'date-time': {
73+
referenceSchemas = datetimeReferenceSchemas(pattern);
74+
break;
75+
}
76+
default: {
77+
referenceSchemas = [];
78+
}
5679
}
57-
58-
const timePattern = innerPattern.slice(0, -zoneSuffix.length);
59-
return (
60-
timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}` ||
61-
timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d` ||
62-
timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}(?::[0-5]\d(?:\.\d+)?)?` ||
63-
ZOD_PRECISION_TIME_PATTERN.test(timePattern)
64-
);
80+
return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined));
6581
}
6682

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

78-
if (parsed.format === 'date-time') {
79-
return isZodIsoDatetimePattern(original.pattern);
80-
}
81-
82-
return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true;
94+
return referencePatternsForFormat(parsed.format, original.pattern).has(original.pattern);
8395
}
8496

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

103+
// The spec declares a closed shape for the `requestedSchema` root: `$schema`, `type`,
104+
// `properties` and `required` only. The wire schema cannot enforce that (its root is a
105+
// catchall so hand-authored extensions stay wire-legal), and the stripped-keys diff below
106+
// never fires for root keys the catchall retains — so converted Standard Schemas are pruned
107+
// here: annotation-only root keywords are dropped, anything else unknown rejects (e.g.
108+
// `z.strictObject()` emits a root `additionalProperties: false`). The keyword set is derived
109+
// from the wire schema so it tracks spec revisions; `$schema` is spec-declared but absent
110+
// from the wire schema's declared keys (the catchall admits it).
111+
const ELICITATION_ROOT_KEYWORDS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]);
112+
const ROOT_ANNOTATION_KEYWORDS = new Set(['title', 'description']);
113+
114+
function pruneElicitationSchemaRoot(schema: Record<string, unknown>): Record<string, unknown> {
115+
const requestedSchema: Record<string, unknown> = {};
116+
const unsupportedKeys: string[] = [];
117+
for (const [key, value] of Object.entries(schema)) {
118+
if (ELICITATION_ROOT_KEYWORDS.has(key)) {
119+
requestedSchema[key] = value;
120+
} else if (!ROOT_ANNOTATION_KEYWORDS.has(key) && !isAnnotationOnlyJsonSchemaKeyword(key)) {
121+
unsupportedKeys.push(key);
122+
}
123+
}
124+
if (unsupportedKeys.length > 0) {
125+
throw new ProtocolError(
126+
ProtocolErrorCode.InvalidParams,
127+
`Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${unsupportedKeys.join(', ')}`
128+
);
129+
}
130+
return requestedSchema;
131+
}
132+
91133
function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] {
92134
if (Array.isArray(original) && Array.isArray(parsed)) {
93135
return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`));
@@ -137,7 +179,7 @@ export function normalizeElicitInputFormParams(
137179
const standardSchema = formParams.requestedSchema;
138180
const normalizedParams = {
139181
...formParams,
140-
requestedSchema: convertStandardElicitationSchema(standardSchema)
182+
requestedSchema: pruneElicitationSchemaRoot(convertStandardElicitationSchema(standardSchema))
141183
};
142184
const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams);
143185
if (!parsedParams.success) {

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* discriminator, and hand-built result literals are equally legal — the
1818
* server seam re-checks the at-least-one rule for them.
1919
*/
20+
import { ProtocolError } from '../types/errors';
2021
import { isInputRequiredResult } from '../types/guards';
2122
import type {
2223
CreateMessageRequestParams,
@@ -128,10 +129,17 @@ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequi
128129
| (Omit<ElicitRequestFormParams, 'mode'> & { mode?: 'form' })
129130
| (Omit<ElicitInputFormParams<StandardSchemaWithJSON>, 'mode'> & { mode?: 'form' })
130131
): InputRequest {
131-
const { params: normalizedParams } = normalizeElicitInputFormParams(
132-
params as ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
133-
);
134-
return { method: 'elicitation/create', params: normalizedParams };
132+
try {
133+
const { params: normalizedParams } = normalizeElicitInputFormParams(
134+
params as ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
135+
);
136+
return { method: 'elicitation/create', params: normalizedParams };
137+
} catch (error) {
138+
// Authoring mistakes in the builder surface as TypeError, matching inputRequired()
139+
// itself; a ProtocolError escaping a prompts/get or resources/read handler would
140+
// reach the client as InvalidParams on its own request.
141+
throw error instanceof ProtocolError ? new TypeError(error.message, { cause: error }) : error;
142+
}
135143
},
136144
elicitUrl(params: Omit<ElicitRequestURLParams, 'mode' | 'elicitationId'>): InputRequest {
137145
// The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, expect, test } from 'vitest';
2+
import * as z from 'zod/v4';
3+
4+
import { normalizeElicitInputFormParams } from '../../src/shared/elicitation';
5+
import { ProtocolError } from '../../src/types/errors';
6+
import type { StandardSchemaWithJSON } from '../../src/util/standardSchema';
7+
8+
function schemaFromJson(jsonSchema: Record<string, unknown>): StandardSchemaWithJSON {
9+
return {
10+
'~standard': {
11+
version: 1,
12+
vendor: 'test',
13+
validate: (value: unknown) => ({ value }),
14+
jsonSchema: {
15+
input: () => jsonSchema,
16+
output: () => ({})
17+
}
18+
}
19+
} satisfies StandardSchemaWithJSON;
20+
}
21+
22+
describe('normalizeElicitInputFormParams root keywords', () => {
23+
test('keeps the spec-declared root keys including $schema', () => {
24+
const { params } = normalizeElicitInputFormParams({
25+
message: 'Name?',
26+
requestedSchema: z.object({ name: z.string() })
27+
});
28+
29+
expect(params.requestedSchema).toEqual({
30+
$schema: 'https://json-schema.org/draft/2020-12/schema',
31+
type: 'object',
32+
properties: { name: { type: 'string' } },
33+
required: ['name']
34+
});
35+
});
36+
37+
test('drops annotation-only root keywords', () => {
38+
const { params } = normalizeElicitInputFormParams({
39+
message: 'Name?',
40+
requestedSchema: z.object({ name: z.string() }).meta({ title: 'User', description: 'User info', 'x-ui-hint': 'compact' })
41+
});
42+
43+
expect(params.requestedSchema).toEqual({
44+
$schema: 'https://json-schema.org/draft/2020-12/schema',
45+
type: 'object',
46+
properties: { name: { type: 'string' } },
47+
required: ['name']
48+
});
49+
});
50+
51+
test.each([
52+
['z.strictObject emits additionalProperties: false', z.strictObject({ name: z.string() }), /additionalProperties/],
53+
['z.looseObject emits additionalProperties: {}', z.looseObject({ name: z.string() }), /additionalProperties/],
54+
[
55+
'catchall emits a nested schema under additionalProperties',
56+
z.object({ name: z.string() }).catchall(z.object({ x: z.string() })),
57+
/additionalProperties/
58+
],
59+
['a root default cannot ride the wire', z.object({ name: z.string() }).default({ name: 'x' }), /default/],
60+
[
61+
'a custom schema emitting $defs',
62+
schemaFromJson({
63+
$defs: { Name: { type: 'string' } },
64+
type: 'object',
65+
properties: { name: { $ref: '#/$defs/Name' } },
66+
required: ['name']
67+
}),
68+
/\$defs/
69+
]
70+
])('rejects unsupported root keywords: %s', (_label, requestedSchema, message) => {
71+
const params = { message: 'Name?', requestedSchema } as Parameters<typeof normalizeElicitInputFormParams>[0];
72+
const act = () => normalizeElicitInputFormParams(params);
73+
expect(act).toThrow(ProtocolError);
74+
expect(act).toThrow(/unsupported JSON Schema keyword\(s\)/);
75+
expect(act).toThrow(message);
76+
});
77+
});
78+
79+
describe('normalizeElicitInputFormParams redundant format patterns', () => {
80+
test.each([
81+
['z.email()', z.email(), 'email'],
82+
['z.url()', z.url(), 'uri'],
83+
['z.iso.date()', z.iso.date(), 'date'],
84+
['z.iso.datetime()', z.iso.datetime(), 'date-time'],
85+
['z.iso.datetime({ offset: true })', z.iso.datetime({ offset: true }), 'date-time'],
86+
['z.iso.datetime({ local: true })', z.iso.datetime({ local: true }), 'date-time'],
87+
['z.iso.datetime({ precision: -1 })', z.iso.datetime({ precision: -1 }), 'date-time'],
88+
['z.iso.datetime({ precision: 0 })', z.iso.datetime({ precision: 0 }), 'date-time'],
89+
['z.iso.datetime({ precision: 3, offset: true })', z.iso.datetime({ precision: 3, offset: true }), 'date-time']
90+
])('accepts %s and strips the zod-emitted pattern', (_label, fieldSchema, format) => {
91+
const { params } = normalizeElicitInputFormParams({
92+
message: 'Value?',
93+
requestedSchema: z.object({ value: fieldSchema })
94+
});
95+
96+
const valueSchema = (params.requestedSchema.properties as Record<string, Record<string, unknown>>).value!;
97+
expect(valueSchema.format).toBe(format);
98+
expect(valueSchema.pattern).toBeUndefined();
99+
});
100+
101+
test('accepts exactly the pattern the installed zod emits for a format', () => {
102+
const emittedPattern = (z.toJSONSchema(z.email(), { target: 'draft-2020-12', io: 'input' }) as Record<string, unknown>)
103+
.pattern as string;
104+
const { params } = normalizeElicitInputFormParams({
105+
message: 'Email?',
106+
requestedSchema: schemaFromJson({
107+
type: 'object',
108+
properties: { email: { type: 'string', format: 'email', pattern: emittedPattern } },
109+
required: ['email']
110+
})
111+
});
112+
113+
const emailSchema = (params.requestedSchema.properties as Record<string, Record<string, unknown>>).email!;
114+
expect(emailSchema.format).toBe('email');
115+
expect(emailSchema.pattern).toBeUndefined();
116+
});
117+
118+
test.each([
119+
['a customized email pattern', z.object({ email: z.email({ pattern: /@corp\.com$/ }) }), /properties\.email\.pattern/],
120+
[
121+
'a pattern the installed zod would not emit for the format',
122+
schemaFromJson({
123+
type: 'object',
124+
properties: { email: { type: 'string', format: 'email', pattern: '^different$' } },
125+
required: ['email']
126+
}),
127+
/properties\.email\.pattern/
128+
],
129+
['a pattern without a supported format', z.object({ code: z.string().regex(/^[A-Z]{3}$/) }), /properties\.code\.pattern/]
130+
])('rejects %s', (_label, requestedSchema, message) => {
131+
const params = { message: 'Value?', requestedSchema } as Parameters<typeof normalizeElicitInputFormParams>[0];
132+
const act = () => normalizeElicitInputFormParams(params);
133+
expect(act).toThrow(ProtocolError);
134+
expect(act).toThrow(message);
135+
});
136+
});

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { describe, expect, test } from 'vitest';
88
import * as z from 'zod/v4';
99

1010
import { acceptedContent, inputRequired, withInputRequired } from '../../src/shared/inputRequired';
11-
import { ProtocolError } from '../../src/types/errors';
1211
import { isInputRequiredResult } from '../../src/types/guards';
1312
import { validateStandardSchema } from '../../src/util/standardSchema';
1413

@@ -103,20 +102,35 @@ describe('inputRequired() builder', () => {
103102
});
104103
});
105104

106-
test('elicit rejects non-object Standard Schema roots as invalid elicitation params', () => {
105+
test('elicit rejects non-object Standard Schema roots as a TypeError', () => {
107106
expect(() =>
108107
inputRequired.elicit({
109108
message: 'Name?',
110109
requestedSchema: z.string()
111110
})
112-
).toThrow(ProtocolError);
111+
).toThrow(TypeError);
113112
expect(() =>
114113
inputRequired.elicit({
115114
message: 'Name?',
116115
requestedSchema: z.string()
117116
})
118117
).toThrow(/Elicitation requestedSchema must describe an object/);
119118
});
119+
120+
test('elicit rejects schemas outside the elicitation subset as a TypeError', () => {
121+
expect(() =>
122+
inputRequired.elicit({
123+
message: 'Name?',
124+
requestedSchema: z.strictObject({ name: z.string() })
125+
})
126+
).toThrow(TypeError);
127+
expect(() =>
128+
inputRequired.elicit({
129+
message: 'Name?',
130+
requestedSchema: z.strictObject({ name: z.string() })
131+
})
132+
).toThrow(/unsupported JSON Schema keyword\(s\).*additionalProperties/);
133+
});
120134
});
121135

122136
describe('acceptedContent()', () => {

0 commit comments

Comments
 (0)