Skip to content

Commit 0ad602d

Browse files
committed
fix(server): allow elicitation string formats from standard schemas
1 parent bb7a377 commit 0ad602d

5 files changed

Lines changed: 34 additions & 7 deletions

File tree

.changeset/standard-schema-elicitation.md

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

66
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
7-
results.
7+
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`.

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ server.setRequestHandler('tools/call', async (request, ctx) => {
703703
});
704704
```
705705

706-
Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels.
706+
Standard Schemas passed to `elicitInput` are converted to MCP's restricted form-elicitation JSON Schema before being sent. They must describe a flat object with primitive properties; accepted responses are parsed with the original schema before `result.content` is returned. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels. Zod string helpers that emit the supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary `.regex()` patterns are rejected because form elicitation does not carry JSON Schema `pattern`.
707707

708708
These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers.
709709

docs/server.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,9 @@ Elicitation lets a tool handler request direct input from the user — form fiel
498498
> Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets.
499499
500500
For form elicitation, pass either the restricted JSON Schema shape used by the MCP wire protocol or a Standard Schema such as a Zod object. Standard Schemas are converted to the restricted elicitation JSON Schema before being sent, so they must describe a flat object with
501-
primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. With Zod v4, use `.meta({ title: 'Field Label' })` for short form-field labels; `.describe()` maps to JSON Schema `description`, not `title`.
501+
primitive properties (`string`, `number`, `integer`, `boolean`, or string enum fields). When the user accepts the form, `result.content` is parsed with the original Standard Schema and is typed as that schema's output. With Zod v4, use `.meta({ title: 'Field Label' })` for short
502+
form-field labels; `.describe()` maps to JSON Schema `description`, not `title`. Zod string helpers that emit the supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary `.regex()` patterns are rejected because form elicitation does not carry JSON Schema
503+
`pattern`.
502504

503505
Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler:
504506

packages/server/src/server/server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
9090
return typeof value === 'object' && value !== null && !Array.isArray(value);
9191
}
9292

93+
const ELICITATION_STRING_FORMATS = new Set(['email', 'uri', 'date', 'date-time']);
94+
95+
function isSupportedFormatPattern(original: Record<string, unknown>, parsed: Record<string, unknown>, key: string): boolean {
96+
return (
97+
key === 'pattern' &&
98+
typeof original.pattern === 'string' &&
99+
parsed.type === 'string' &&
100+
typeof parsed.format === 'string' &&
101+
original.format === parsed.format &&
102+
ELICITATION_STRING_FORMATS.has(parsed.format)
103+
);
104+
}
105+
93106
function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] {
94107
if (Array.isArray(original) && Array.isArray(parsed)) {
95108
return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`));
@@ -102,6 +115,9 @@ function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path =
102115
return Object.entries(original).flatMap(([key, value]) => {
103116
const childPath = path ? `${path}.${key}` : key;
104117
if (!Object.prototype.hasOwnProperty.call(parsed, key)) {
118+
if (isSupportedFormatPattern(original, parsed, key)) {
119+
return [];
120+
}
105121
return [childPath];
106122
}
107123
return findStrippedJsonSchemaPaths(value, parsed[key], childPath);

packages/server/test/server/jsonSchemaValidatorOverride.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,14 @@ describe('server JSON Schema validator overrides', () => {
124124
await clientTransport.send({
125125
jsonrpc: '2.0',
126126
id: message.id,
127-
result: { action: 'accept', content: { count: '5' } }
127+
result: { action: 'accept', content: { count: '5', email: 'user@example.com' } }
128128
});
129129
}
130130
};
131131

132132
const schema = z.object({
133133
count: z.coerce.number().min(1).meta({ title: 'Registration Count', description: 'Number of registrations to process' }),
134+
email: z.string().email().meta({ title: 'Email', description: 'Email address' }),
134135
newsletter: z.boolean().default(false)
135136
});
136137

@@ -142,8 +143,8 @@ describe('server JSON Schema validator overrides', () => {
142143
const result = await server.elicitInput(params);
143144

144145
expectTypeOf(result).toMatchTypeOf<ElicitInputResult<typeof schema>>();
145-
expectTypeOf(result.content).toEqualTypeOf<{ count: number; newsletter: boolean } | undefined>();
146-
expect(result).toEqual({ action: 'accept', content: { count: 5, newsletter: false } });
146+
expectTypeOf(result.content).toEqualTypeOf<{ count: number; email: string; newsletter: boolean } | undefined>();
147+
expect(result).toEqual({ action: 'accept', content: { count: 5, email: 'user@example.com', newsletter: false } });
147148
expect(requestedSchema).toMatchObject({
148149
type: 'object',
149150
properties: {
@@ -153,10 +154,18 @@ describe('server JSON Schema validator overrides', () => {
153154
title: 'Registration Count',
154155
description: 'Number of registrations to process'
155156
},
157+
email: {
158+
type: 'string',
159+
format: 'email',
160+
title: 'Email',
161+
description: 'Email address'
162+
},
156163
newsletter: { type: 'boolean', default: false }
157164
},
158-
required: ['count']
165+
required: ['count', 'email']
159166
});
167+
const emailSchema = (requestedSchema!.properties as Record<string, Record<string, unknown>>).email!;
168+
expect(emailSchema.pattern).toBeUndefined();
160169
expect(validator.schemas).toEqual([]);
161170
expect(validator.values).toEqual([]);
162171

0 commit comments

Comments
 (0)