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:
parent
cc70c5e6a9
commit
24be4040d4
9 changed files with 236 additions and 17 deletions
6
.changeset/standard-schema-elicitation.md
Normal file
6
.changeset/standard-schema-elicitation.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
});
|
||||
|
|
|
|||
85
packages/core-internal/src/shared/elicitation.ts
Normal file
85
packages/core-internal/src/shared/elicitation.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue