diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md new file mode 100644 index 0000000000..d4b1d86e06 --- /dev/null +++ b/.changeset/standard-schema-elicitation.md @@ -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. diff --git a/docs/servers/input-required.md b/docs/servers/input-required.md index 6f731504f9..595260f1e5 100644 --- a/docs/servers/input-required.md +++ b/docs/servers/input-required.md @@ -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 => { - 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: diff --git a/examples/guides/servers/input-required.examples.ts b/examples/guides/servers/input-required.examples.ts index bef7a1d403..8b3755ca17 100644 --- a/examples/guides/servers/input-required.examples.ts +++ b/examples/guides/servers/input-required.examples.ts @@ -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 => { - 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 }) } }); diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts new file mode 100644 index 0000000000..b322f4f8f2 --- /dev/null +++ b/packages/core-internal/src/shared/elicitation.ts @@ -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 & { + mode?: 'form'; + requestedSchema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON; +}; + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Record { + 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; +} diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 0c7ba043e1..4a9fe618dd 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -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 & { 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 & { 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): InputRequest { // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index 2bce27fcf1..b59a9f02da 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -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()', () => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dd720eb954..0d4b7c0484 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 diff --git a/packages/server/test/server/inputRequired.test.ts b/packages/server/test/server/inputRequired.test.ts index 22dde018b8..8dfae39b63 100644 --- a/packages/server/test/server/inputRequired.test.ts +++ b/packages/server/test/server/inputRequired.test.ts @@ -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(); diff --git a/packages/server/test/server/legacyInputRequiredShim.test.ts b/packages/server/test/server/legacyInputRequiredShim.test.ts index 62d640b179..ce5a384df8 100644 --- a/packages/server/test/server/legacyInputRequiredShim.test.ts +++ b/packages/server/test/server/legacyInputRequiredShim.test.ts @@ -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[1]) { const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options }); const seenResponses: Array | 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');