feat(server): accept Standard Schemas in inputRequired.elicit (#2369)

Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
Matt 2026-07-09 11:04:16 +01:00 • committed by GitHub
commit 24be4040d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 236 additions and 17 deletions

View file

@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core-internal': minor
'@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.

View file

@ -10,6 +10,10 @@ An **`input_required`** result is how a `tools/call`, `prompts/get`, or `resourc
The handler reads what already arrived with `acceptedContent`; while the answer is missing it returns `inputRequired(...)` instead of a tool result.
```ts source="../../examples/guides/servers/input-required.examples.ts#registerTool_inputRequired"
const confirmationSchema = z.object({
confirm: z.boolean().meta({ title: 'Confirm deployment' })
});
server.registerTool(
'deploy',
{
@ -17,13 +21,13 @@ server.registerTool(
inputSchema: z.object({ env: z.string() })
},
async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema);
if (confirmed?.confirm !== true) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${env}?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
requestedSchema: confirmationSchema
})
}
});
@ -33,7 +37,7 @@ server.registerTool(
);
```
The first round returns `resultType: 'input_required'` carrying the `confirm` request. The client fulfils it and retries `deploy` with the answer in `inputResponses`; on re-entry `acceptedContent` finds it and the handler finishes.
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.
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

@ -36,6 +36,10 @@ const server = new McpServer({ name: 'releases', version: '1.0.0' }, { requestSt
// "Return `input_required` instead of pushing a request"
//#region registerTool_inputRequired
const confirmationSchema = z.object({
confirm: z.boolean().meta({ title: 'Confirm deployment' })
});
server.registerTool(
'deploy',
{
@ -43,13 +47,13 @@ server.registerTool(
inputSchema: z.object({ env: z.string() })
},
async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema);
if (confirmed?.confirm !== true) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${env}?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
requestedSchema: confirmationSchema
})
}
});

View file

@ -0,0 +1,85 @@
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 type { StandardSchemaWithJSON } from '../util/standardSchema';
import { isStandardSchemaWithJSON, standardSchemaToJsonSchema } from '../util/standardSchema';
/** Input accepted by `inputRequired.elicit()`. */
export type ElicitInputParams = Omit<ElicitRequestFormParams, 'mode' | 'requestedSchema'> & {
mode?: 'form';
requestedSchema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON;
};
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Record<string, unknown> {
try {
return standardSchemaToJsonSchema(schema, 'input');
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema must describe an object with flat primitive properties: ${detail}`
);
}
}
const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', '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}]`));
}
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);
});
}
/** Converts an authoring-friendly elicitation input into its wire-ready form. */
export function normalizeElicitInputParams(input: ElicitInputParams): ElicitRequestFormParams {
if (!isStandardSchemaWithJSON(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);
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}`
);
}
const strippedConstraints = findStrippedConstraintPaths(convertedSchema, parsed.data.requestedSchema);
if (strippedConstraints.length > 0) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema contains unsupported JSON Schema constraint(s) after Standard Schema conversion: ${strippedConstraints.join(', ')}`
);
}
return parsed.data;
}

View file

@ -17,12 +17,12 @@
* discriminator, and hand-built result literals are equally legal — the
* server seam re-checks the at-least-one rule for them.
*/
import { ProtocolError } from '../types/errors';
import { isInputRequiredResult } from '../types/guards';
import type {
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
ElicitRequestFormParams,
ElicitRequestURLParams,
InputRequest,
InputRequests,
@ -31,6 +31,10 @@ import type {
Root
} from '../types/types';
import type { StandardSchemaV1 } from '../util/standardSchema';
import type { ElicitInputParams } from './elicitation';
import { normalizeElicitInputParams } from './elicitation';
export type { ElicitInputParams } from './elicitation';
/** The shape accepted by {@linkcode inputRequired}. */
export interface InputRequiredSpec {
@ -58,7 +62,7 @@ interface InputRequiredBuilder {
(spec: InputRequiredSpec): InputRequiredResult;
/** Builds an embedded form-mode elicitation request (`elicitation/create`). */
elicit(params: Omit<ElicitRequestFormParams, 'mode'> & { mode?: 'form' }): InputRequest;
elicit(params: ElicitInputParams): InputRequest;
/**
* Builds an embedded URL-mode elicitation request (`elicitation/create`).
@ -118,8 +122,12 @@ function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult {
* ```
*/
export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequired, {
elicit(params: Omit<ElicitRequestFormParams, 'mode'> & { mode?: 'form' }): InputRequest {
return { method: 'elicitation/create', params: { ...params, mode: 'form' } };
elicit(params: ElicitInputParams): InputRequest {
try {
return { method: 'elicitation/create', params: normalizeElicitInputParams(params) };
} catch (error) {
throw error instanceof ProtocolError ? new TypeError(error.message, { cause: error }) : error;
}
},
elicitUrl(params: Omit<ElicitRequestURLParams, 'mode' | 'elicitationId'>): InputRequest {
// The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the

View file

@ -51,6 +51,95 @@ describe('inputRequired() builder', () => {
});
expect(inputRequired.listRoots()).toEqual({ method: 'roots/list' });
});
test('elicit converts a Standard Schema to the restricted wire schema', () => {
const request = inputRequired.elicit({
message: 'Registration details?',
requestedSchema: z.object({
email: z.string().meta({ title: 'Email', format: 'email' }),
count: z.number().min(1).max(5),
role: z.enum(['admin', 'member'])
})
});
expect(request).toEqual({
method: 'elicitation/create',
params: {
mode: 'form',
message: 'Registration details?',
requestedSchema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
email: { type: 'string', title: 'Email', format: 'email' },
count: { type: 'number', minimum: 1, maximum: 5 },
role: { type: 'string', enum: ['admin', 'member'] }
},
required: ['email', 'count', 'role']
}
}
});
});
test('elicit drops annotation-only metadata from Standard Schema properties', () => {
const request = inputRequired.elicit({
message: 'Name?',
requestedSchema: z.object({
name: z.string().meta({
title: 'Name',
examples: ['Ada Lovelace'],
deprecated: false,
readOnly: true,
'x-ui-order': 1
})
})
});
expect(request).toEqual({
method: 'elicitation/create',
params: {
mode: 'form',
message: 'Name?',
requestedSchema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: { name: { type: 'string', title: 'Name' } },
required: ['name']
}
}
});
});
test('elicit rejects non-object Standard Schema roots as a local type error', () => {
expect(() =>
inputRequired.elicit({
message: 'Name?',
requestedSchema: z.string()
})
).toThrow(TypeError);
expect(() =>
inputRequired.elicit({
message: 'Name?',
requestedSchema: z.string()
})
).toThrow(/Elicitation requestedSchema must describe an object/);
});
test('elicit rejects validation constraints the restricted wire schema cannot express', () => {
expect(() =>
inputRequired.elicit({
message: 'Code?',
requestedSchema: z.object({ code: z.string().regex(/^[A-Z]{3}$/) })
})
).toThrow(/properties\.code\.pattern/);
expect(() =>
inputRequired.elicit({
message: 'Address?',
requestedSchema: z.object({ address: z.object({ city: z.string() }) })
})
).toThrow(/flat primitive properties/);
});
});
describe('acceptedContent()', () => {

View file

@ -96,7 +96,7 @@ export type { CacheHint, CacheScope } from '@modelcontextprotocol/core-internal'
// helpers a handler uses to request additional client input by returning an
// input-required result instead of sending a server→client request, and the
// typed readers for the responses a retried request carries back.
export type { InputRequiredSpec, InputResponseView } from '@modelcontextprotocol/core-internal';
export type { ElicitInputParams, InputRequiredSpec, InputResponseView } from '@modelcontextprotocol/core-internal';
export { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal';
// re-export curated public API from core

View file

@ -119,17 +119,18 @@ function errorOf(message: JSONRPCMessage): { code: number; message: string; data
describe('input-required returns on the 2026-07-28 era', () => {
it('a write-once tool returning inputRequired() reaches the wire as input_required and completes on the retry', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
const confirmationSchema = z.object({ confirm: z.boolean().meta({ title: 'Confirm deployment' }) });
server.registerTool(
'deploy',
{ inputSchema: z.object({ env: z.string() }), outputSchema: z.object({ deployed: z.boolean() }) },
async ({ env }, ctx) => {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema);
if (!confirmed?.confirm) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${env}?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } } }
requestedSchema: confirmationSchema
})
},
requestState: 'opaque-deploy-state'
@ -147,7 +148,19 @@ describe('input-required returns on the 2026-07-28 era', () => {
);
expect(first.resultType).toBe('input_required');
expect(first.requestState).toBe('opaque-deploy-state');
expect(first.inputRequests).toMatchObject({ confirm: { method: 'elicitation/create' } });
expect(first.inputRequests).toMatchObject({
confirm: {
method: 'elicitation/create',
params: {
mode: 'form',
requestedSchema: {
type: 'object',
properties: { confirm: { type: 'boolean', title: 'Confirm deployment' } },
required: ['confirm']
}
}
}
});
expect(first.ttlMs).toBeUndefined();
expect(first.cacheScope).toBeUndefined();
expect(first.content).toBeUndefined();

View file

@ -35,19 +35,21 @@ import { errorOf, legacyInitialize, resultOf, toolText, wireLegacy } from './leg
import { McpServer } from '../../src/server/mcp';
import { Server } from '../../src/server/server';
const CONFIRM_SCHEMA = z.object({ confirm: z.boolean().meta({ title: 'Confirm deployment' }) });
/** A sessionful legacy connection serving one write-once elicitation tool. */
async function elicitingToolServer(options?: ConstructorParameters<typeof McpServer>[1]) {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options });
const seenResponses: Array<Record<string, unknown> | undefined> = [];
server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => {
seenResponses.push(ctx.mcpReq.inputResponses);
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', CONFIRM_SCHEMA);
if (!confirmed?.confirm) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${env}?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
requestedSchema: CONFIRM_SCHEMA
})
}
});
@ -82,10 +84,18 @@ describe('legacy shim: write-once fulfilment on a sessionful 2025-era connection
// The mis-typed input_required result never reached the wire.
expect(resultOf(answer).resultType).toBeUndefined();
// A real wire request went out, form-mode, with the handler's params.
// A real wire request went out, form-mode, with the Standard Schema converted to MCP's restricted JSON Schema.
const legs = wire.peerRequests('elicitation/create');
expect(legs).toHaveLength(1);
expect(legs[0]!.params).toMatchObject({ mode: 'form', message: 'Deploy to prod?' });
expect(legs[0]!.params).toMatchObject({
mode: 'form',
message: 'Deploy to prod?',
requestedSchema: {
type: 'object',
properties: { confirm: { type: 'boolean', title: 'Confirm deployment' } },
required: ['confirm']
}
});
// Stream association: the leg is stamped with the ORIGINATING request id.
const [legOptions] = wire.sentOptionsFor('elicitation/create');