Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .changeset/standard-schema-elicitation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/client': minor
---

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
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`. The converted schema's root is held to the spec's shape (`type`, `properties`, `required`, `$schema`): unknown root keywords — such as the `additionalProperties` emitted by `z.strictObject()` — are rejected before sending, and annotation-only root keywords (`title`, `description`) are dropped.
2 changes: 2 additions & 0 deletions packages/core-internal/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export { getDisplayName } from '../../shared/metadataUtils';
export type {
BaseContext,
ClientContext,
ElicitInputFormParams,
ElicitInputResult,
NotificationOptions,
ProgressCallback,
ProtocolOptions,
Expand Down
1 change: 1 addition & 0 deletions packages/core-internal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './errors/sdkErrors';
export * from './shared/auth';
export * from './shared/authUtils';
export * from './shared/clientCapabilityRequirements';
export * from './shared/elicitation';
export * from './shared/envelope';
export * from './shared/inboundClassification';
export * from './shared/inputRequired';
Expand Down
207 changes: 207 additions & 0 deletions packages/core-internal/src/shared/elicitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import * as z from 'zod/v4';

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 { isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema';
import type { ElicitInputFormParams } from './protocol';

export type NormalizedElicitInputFormParams = {
params: ElicitRequestFormParams;
standardSchema?: StandardSchemaWithJSON;
};

function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

// A `pattern` emitted beside a supported `format` is redundant — zod realizes every format
// check as a regex — and is dropped so the wire schema stays within the elicitation subset.
// The reference patterns are derived from the installed zod at runtime rather than vendored
// as string literals: zod's format regexes change across in-range releases, so a vendored
// copy would start rejecting schemas produced by any newer zod while CI (lockfile-pinned)
// stays green. A pattern the installed zod would not emit for that format — e.g. a
// customized `z.email({ pattern })` — still rejects, because the wire schema cannot carry it.
// Residual limitation: if the app resolves a second zod copy whose regexes differ from this
// package's resolved zod, its emissions won't match the reference and reject (fail closed);
// zod is a peer dependency precisely so installs dedupe to one copy.
// Derivation is cheap (a handful of toJSONSchema calls) and only runs when a stripped
// `pattern` sits beside a matching `format`, so no caching.

function zodEmittedPattern(schema: z.ZodType): string | undefined {
// Conversion options must stay in lockstep with standardSchemaToJsonSchema (which
// produced the pattern under comparison via `~standard.jsonSchema.input`).
const jsonSchema = z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' }) as Record<string, unknown>;
return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined;
}

const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/;

function datetimeReferenceSchemas(pattern: string): z.ZodType[] {
// The emitted pattern depends on the authoring options (offset/local/precision); the
// fraction-digit count recovered from the pattern under test keeps the candidate set
// finite. Duplicate candidates are fine — the result feeds a Set.
const fractionDigits = DATETIME_FRACTION_DIGITS.exec(pattern);
const precisions: Array<number | undefined> = [undefined, -1, 0];
if (fractionDigits) {
precisions.push(Number(fractionDigits[1]));
}
return [false, true].flatMap(local =>
[false, true].flatMap(offset => precisions.map(precision => z.iso.datetime({ local, offset, precision })))
);
}

function referencePatternsForFormat(format: string, pattern: string): ReadonlySet<string> {
let referenceSchemas: z.ZodType[];
switch (format) {
case 'email': {
referenceSchemas = [z.email()];
break;
}
case 'uri': {
referenceSchemas = [z.url()];
break;
}
case 'date': {
referenceSchemas = [z.iso.date()];
break;
}
case 'date-time': {
referenceSchemas = datetimeReferenceSchemas(pattern);
break;
}
default: {
referenceSchemas = [];
}
}
return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined));
}

function isRedundantFormatPattern(original: Record<string, unknown>, parsed: Record<string, unknown>, key: string): boolean {
if (
key !== 'pattern' ||
typeof original.pattern !== 'string' ||
parsed.type !== 'string' ||
typeof parsed.format !== 'string' ||
original.format !== parsed.format
) {
return false;
}

return referencePatternsForFormat(parsed.format, original.pattern).has(original.pattern);
}

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-');
}

// The spec declares a closed shape for the `requestedSchema` root: `$schema`, `type`,
// `properties` and `required` only. The wire schema cannot enforce that (its root is a
// catchall so hand-authored extensions stay wire-legal), and the stripped-keys diff below
// never fires for root keys the catchall retains — so converted Standard Schemas are pruned
// here: annotation-only root keywords are dropped, anything else unknown rejects (e.g.
// `z.strictObject()` emits a root `additionalProperties: false`). The keyword set is derived
// from the wire schema so it tracks spec revisions; `$schema` is spec-declared but absent
// from the wire schema's declared keys (the catchall admits it).
const ELICITATION_ROOT_KEYWORDS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]);
const ROOT_ANNOTATION_KEYWORDS = new Set(['title', 'description']);

function pruneElicitationSchemaRoot(schema: Record<string, unknown>): Record<string, unknown> {
const requestedSchema: Record<string, unknown> = {};
const unsupportedKeys: string[] = [];
for (const [key, value] of Object.entries(schema)) {
if (ELICITATION_ROOT_KEYWORDS.has(key)) {
requestedSchema[key] = value;
} else if (!ROOT_ANNOTATION_KEYWORDS.has(key) && !isAnnotationOnlyJsonSchemaKeyword(key)) {
unsupportedKeys.push(key);
}
}
if (unsupportedKeys.length > 0) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${unsupportedKeys.join(', ')}`
);
}
return requestedSchema;
}

function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] {
if (Array.isArray(original) && Array.isArray(parsed)) {
return original.flatMap((item, index) => findStrippedJsonSchemaPaths(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)) {
if (isRedundantFormatPattern(original, parsed, key) || isAnnotationOnlyJsonSchemaKeyword(key)) {
return [];
}
return [childPath];
}
return findStrippedJsonSchemaPaths(value, parsed[key], childPath);
});
}

function isElicitInputSchema(
schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON
): schema is StandardSchemaWithJSON {
// isStandardSchema, not a plain '~standard' key probe: ArkType schemas are functions
// (a typeof-object check routes them, unconverted, to the raw branch), and a plain JSON
// schema containing a literal '~standard' key — wire-legal via the catchall — must stay
// on the raw branch. Not isStandardSchemaWithJSON: gating on `~standard.jsonSchema` here
// would front-run standardSchemaToJsonSchema's zod 4.0/4.1 fallback (see zodCompat.ts).
return isStandardSchema(schema);
}

function convertStandardElicitationSchema(standardSchema: StandardSchemaWithJSON): Record<string, unknown> {
try {
return standardSchemaToJsonSchema(standardSchema, '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}`
);
}
}

export function normalizeElicitInputFormParams(
params: ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
): NormalizedElicitInputFormParams {
const formParams =
params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' as const };

if (isElicitInputSchema(formParams.requestedSchema)) {
const standardSchema = formParams.requestedSchema;
const normalizedParams = {
...formParams,
requestedSchema: pruneElicitationSchemaRoot(convertStandardElicitationSchema(standardSchema))
};
const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams);
if (!parsedParams.success) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}`
);
}
const strippedSchemaPaths = findStrippedJsonSchemaPaths(normalizedParams.requestedSchema, parsedParams.data.requestedSchema);
if (strippedSchemaPaths.length > 0) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${strippedSchemaPaths.join(', ')}`
);
}
return { params: parsedParams.data, standardSchema };
}

return { params: formParams };
}
26 changes: 23 additions & 3 deletions packages/core-internal/src/shared/inputRequired.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* 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,
Expand All @@ -30,7 +31,9 @@ import type {
InputResponses,
Root
} from '../types/types';
import type { StandardSchemaV1 } from '../util/standardSchema';
import type { StandardSchemaV1, StandardSchemaWithJSON } from '../util/standardSchema';
import { normalizeElicitInputFormParams } from './elicitation';
import type { ElicitInputFormParams } from './protocol';

/** The shape accepted by {@linkcode inputRequired}. */
export interface InputRequiredSpec {
Expand All @@ -57,6 +60,9 @@ interface InputRequiredBuilder {
*/
(spec: InputRequiredSpec): InputRequiredResult;

/** Builds an embedded form-mode elicitation request (`elicitation/create`). */
elicit<Schema extends StandardSchemaWithJSON>(params: Omit<ElicitInputFormParams<Schema>, 'mode'> & { mode?: 'form' }): InputRequest;

/** Builds an embedded form-mode elicitation request (`elicitation/create`). */
elicit(params: Omit<ElicitRequestFormParams, 'mode'> & { mode?: 'form' }): InputRequest;

Expand Down Expand Up @@ -118,8 +124,22 @@ 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:
| (Omit<ElicitRequestFormParams, 'mode'> & { mode?: 'form' })
| (Omit<ElicitInputFormParams<StandardSchemaWithJSON>, 'mode'> & { mode?: 'form' })
): InputRequest {
try {
const { params: normalizedParams } = normalizeElicitInputFormParams(
params as ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
);
return { method: 'elicitation/create', params: normalizedParams };
} catch (error) {
// Authoring mistakes in the builder surface as TypeError, matching inputRequired()
// itself; a ProtocolError escaping a prompts/get or resources/read handler would
// reach the client as InvalidParams on its own request.
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
22 changes: 20 additions & 2 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
ProtocolErrorCode,
SUPPORTED_PROTOCOL_VERSIONS
} from '../types/index';
import type { StandardSchemaV1 } from '../util/standardSchema';
import type { StandardSchemaV1, StandardSchemaWithJSON } from '../util/standardSchema';
import { isStandardSchema, validateStandardSchema } from '../util/standardSchema';
import { bootstrapOutboundCodec } from '../wire/bootstrap';
import type { LiftedWireMaterial, WireCodec } from '../wire/codec';
Expand Down Expand Up @@ -444,6 +444,18 @@ export type BaseContext = {
};
};

export type ElicitInputFormParams<Schema extends StandardSchemaWithJSON = StandardSchemaWithJSON> = Omit<
ElicitRequestFormParams,
'requestedSchema'
> & {
requestedSchema: Schema;
};

export type ElicitInputResult<Schema extends StandardSchemaWithJSON> = Result & {
action: ElicitResult['action'];
content?: StandardSchemaWithJSON.InferOutput<Schema>;
};

/**
* Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields.
*/
Expand All @@ -467,7 +479,13 @@ export type ServerContext = BaseContext & {
* replaced by input_required results in the 2026-07-28 protocol. If your factory serves
* both eras, this only works on the legacy path.
*/
elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise<ElicitResult>;
elicitInput: {
<Schema extends StandardSchemaWithJSON>(
params: ElicitInputFormParams<Schema>,
options?: RequestOptions
): Promise<ElicitInputResult<Schema>>;
(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise<ElicitResult>;
};

/**
* Request LLM sampling from the client.
Expand Down
Loading
Loading