Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/standard-schema-elicitation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/server': minor
---
Comment thread
mattzcarey marked this conversation as resolved.

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.
10 changes: 7 additions & 3 deletions docs/servers/input-required.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ 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',
{
description: 'Deploy after the operator confirms',
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
})
}
});
Expand All @@ -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:

Expand Down
8 changes: 6 additions & 2 deletions examples/guides/servers/input-required.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,24 @@ 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',
{
description: 'Deploy after the operator confirms',
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
})
}
});
Expand Down
85 changes: 85 additions & 0 deletions packages/core-internal/src/shared/elicitation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 12 additions & 4 deletions packages/core-internal/src/shared/inputRequired.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions packages/core-internal/test/shared/inputRequired.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions packages/server/test/server/inputRequired.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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();
Expand Down
18 changes: 14 additions & 4 deletions packages/server/test/server/legacyInputRequiredShim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
});
Expand Down Expand Up @@ -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');
Expand Down
Loading