DRAFT: SEP-1034: Default values for Elicitation Schemas (#1096)

This commit is contained in:
Konstantin Konstantinov 2025-11-11 16:11:42 +02:00 • committed by GitHub
commit ce420f8f94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 212 additions and 22 deletions

View file

@ -36,10 +36,41 @@ import {
SUPPORTED_PROTOCOL_VERSIONS,
type SubscribeRequest,
type Tool,
type UnsubscribeRequest
type UnsubscribeRequest,
ElicitResultSchema,
ElicitRequestSchema
} from '../types.js';
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
import { ZodLiteral, ZodObject, z } from 'zod';
import type { RequestHandlerExtra } from '../shared/protocol.js';
/**
* Elicitation default application helper. Applies defaults to the data based on the schema.
*
* @param schema - The schema to apply defaults to.
* @param data - The data to apply defaults to.
*/
function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unknown): void {
if (!schema || data === null || typeof data !== 'object') return;
// Handle object properties
if (schema.type === 'object' && schema.properties && typeof schema.properties === 'object') {
const obj = data as Record<string, unknown>;
const props = schema.properties as Record<string, JsonSchemaType & { default?: unknown }>;
for (const key of Object.keys(props)) {
const propSchema = props[key];
// If missing or explicitly undefined, apply default if present
if (obj[key] === undefined && Object.prototype.hasOwnProperty.call(propSchema, 'default')) {
obj[key] = propSchema.default;
}
// Recurse into existing nested objects/arrays
if (obj[key] !== undefined) {
applyElicitationDefaults(propSchema, obj[key]);
}
}
}
}
export type ClientOptions = ProtocolOptions & {
/**
@ -141,6 +172,64 @@ export class Client<
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
}
/**
* Override request handler registration to enforce client-side validation for elicitation.
*/
public override setRequestHandler<
T extends ZodObject<{
method: ZodLiteral<string>;
}>
>(
requestSchema: T,
handler: (
request: z.infer<T>,
extra: RequestHandlerExtra<ClientRequest | RequestT, ClientNotification | NotificationT>
) => ClientResult | ResultT | Promise<ClientResult | ResultT>
): void {
const method = requestSchema.shape.method.value;
if (method === 'elicitation/create') {
const wrappedHandler = async (
request: z.infer<T>,
extra: RequestHandlerExtra<ClientRequest | RequestT, ClientNotification | NotificationT>
): Promise<ClientResult | ResultT> => {
const validatedRequest = ElicitRequestSchema.safeParse(request);
if (!validatedRequest.success) {
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${validatedRequest.error.message}`);
}
const result = await Promise.resolve(handler(request, extra));
const validationResult = ElicitResultSchema.safeParse(result);
if (!validationResult.success) {
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation result: ${validationResult.error.message}`);
}
const validatedResult = validationResult.data;
if (
this._capabilities.elicitation?.applyDefaults &&
validatedResult.action === 'accept' &&
validatedResult.content &&
validatedRequest.data.params.requestedSchema
) {
try {
applyElicitationDefaults(validatedRequest.data.params.requestedSchema, validatedResult.content);
} catch {
// gracefully ignore errors in default application
}
}
return validatedResult;
};
// Install the wrapped handler
return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler);
}
// Non-elicitation handlers use default behavior
return super.setRequestHandler(requestSchema, handler);
}
protected assertCapability(capability: keyof ServerCapabilities, method: string): void {
if (!this._serverCapabilities?.[capability]) {
throw new Error(`Server does not support ${capability} (required for ${method})`);

View file

@ -609,6 +609,72 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
});
});
test(`${validatorName}: should default missing fields from schema defaults`, async () => {
const server = new Server(
{ name: 'test-server', version: '1.0.0' },
{
capabilities: {},
jsonSchemaValidator: validatorProvider
}
);
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{
capabilities: {
elicitation: {
applyDefaults: true
}
}
}
);
// Client returns no values; SDK should apply defaults automatically (and validate)
client.setRequestHandler(ElicitRequestSchema, request => {
expect(request.params.requestedSchema).toEqual({
type: 'object',
properties: {
subscribe: { type: 'boolean', default: true },
nickname: { type: 'string', default: 'Guest' },
age: { type: 'integer', minimum: 0, maximum: 150, default: 18 },
color: { type: 'string', enum: ['red', 'green'], default: 'green' }
},
required: ['subscribe', 'nickname', 'age', 'color']
});
return {
action: 'accept',
content: {}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const result = await server.elicitInput({
message: 'Provide your preferences',
requestedSchema: {
type: 'object',
properties: {
subscribe: { type: 'boolean', default: true },
nickname: { type: 'string', default: 'Guest' },
age: { type: 'integer', minimum: 0, maximum: 150, default: 18 },
color: { type: 'string', enum: ['red', 'green'], default: 'green' }
},
required: ['subscribe', 'nickname', 'age', 'color']
}
});
expect(result).toEqual({
action: 'accept',
content: {
subscribe: true,
nickname: 'Guest',
age: 18,
color: 'green'
}
});
});
test(`${validatorName}: should reject invalid email format`, async () => {
const server = new Server(
{ name: 'test-server', version: '1.0.0' },

View file

@ -62,6 +62,19 @@ type MakeUnknownsNotOptional<T> =
}
: T;
// Targeted fix: in spec, treat ClientCapabilities.elicitation?: object as Record<string, unknown>
type FixSpecClientCapabilities<T> = T extends { elicitation?: object }
? Omit<T, 'elicitation'> & { elicitation?: Record<string, unknown> }
: T;
type FixSpecInitializeRequestParams<T> = T extends { capabilities: infer C }
? Omit<T, 'capabilities'> & { capabilities: FixSpecClientCapabilities<C> }
: T;
type FixSpecInitializeRequest<T> = T extends { params: infer P } ? Omit<T, 'params'> & { params: FixSpecInitializeRequestParams<P> } : T;
type FixSpecClientRequest<T> = T extends { params: infer P } ? Omit<T, 'params'> & { params: FixSpecInitializeRequestParams<P> } : T;
const sdkTypeChecks = {
RequestParams: (sdk: SDKTypes.RequestParams, spec: SpecTypes.RequestParams) => {
sdk = spec;
@ -75,7 +88,10 @@ const sdkTypeChecks = {
sdk = spec;
spec = sdk;
},
InitializeRequestParams: (sdk: SDKTypes.InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => {
InitializeRequestParams: (
sdk: SDKTypes.InitializeRequestParams,
spec: FixSpecInitializeRequestParams<SpecTypes.InitializeRequestParams>
) => {
sdk = spec;
spec = sdk;
},
@ -480,7 +496,10 @@ const sdkTypeChecks = {
sdk = spec;
spec = sdk;
},
InitializeRequest: (sdk: WithJSONRPCRequest<SDKTypes.InitializeRequest>, spec: SpecTypes.InitializeRequest) => {
InitializeRequest: (
sdk: WithJSONRPCRequest<SDKTypes.InitializeRequest>,
spec: FixSpecInitializeRequest<SpecTypes.InitializeRequest>
) => {
sdk = spec;
spec = sdk;
},
@ -488,7 +507,7 @@ const sdkTypeChecks = {
sdk = spec;
spec = sdk;
},
ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => {
ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: FixSpecClientCapabilities<SpecTypes.ClientCapabilities>) => {
sdk = spec;
spec = sdk;
},
@ -496,7 +515,10 @@ const sdkTypeChecks = {
sdk = spec;
spec = sdk;
},
ClientRequest: (sdk: RemovePassthrough<WithJSONRPCRequest<SDKTypes.ClientRequest>>, spec: SpecTypes.ClientRequest) => {
ClientRequest: (
sdk: RemovePassthrough<WithJSONRPCRequest<SDKTypes.ClientRequest>>,
spec: FixSpecClientRequest<SpecTypes.ClientRequest>
) => {
sdk = spec;
spec = sdk;
},

View file

@ -286,7 +286,17 @@ export const ClientCapabilitiesSchema = z.object({
/**
* Present if the client supports eliciting user input.
*/
elicitation: AssertObjectSchema.optional(),
elicitation: z.intersection(
z
.object({
/**
* Whether the client should apply defaults to the user input.
*/
applyDefaults: z.boolean().optional()
})
.optional(),
z.record(z.string(), z.unknown()).optional()
),
/**
* Present if the client supports listing roots.
*/
@ -1198,9 +1208,9 @@ export const CreateMessageResultSchema = ResultSchema.extend({
*/
export const BooleanSchemaSchema = z.object({
type: z.literal('boolean'),
title: z.optional(z.string()),
description: z.optional(z.string()),
default: z.optional(z.boolean())
title: z.string().optional(),
description: z.string().optional(),
default: z.boolean().optional()
});
/**
@ -1208,11 +1218,12 @@ export const BooleanSchemaSchema = z.object({
*/
export const StringSchemaSchema = z.object({
type: z.literal('string'),
title: z.optional(z.string()),
description: z.optional(z.string()),
minLength: z.optional(z.number()),
maxLength: z.optional(z.number()),
format: z.optional(z.enum(['email', 'uri', 'date', 'date-time']))
title: z.string().optional(),
description: z.string().optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
format: z.enum(['email', 'uri', 'date', 'date-time']).optional(),
default: z.string().optional()
});
/**
@ -1220,10 +1231,11 @@ export const StringSchemaSchema = z.object({
*/
export const NumberSchemaSchema = z.object({
type: z.enum(['number', 'integer']),
title: z.optional(z.string()),
description: z.optional(z.string()),
minimum: z.optional(z.number()),
maximum: z.optional(z.number())
title: z.string().optional(),
description: z.string().optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
default: z.number().optional()
});
/**
@ -1231,16 +1243,17 @@ export const NumberSchemaSchema = z.object({
*/
export const EnumSchemaSchema = z.object({
type: z.literal('string'),
title: z.optional(z.string()),
description: z.optional(z.string()),
title: z.string().optional(),
description: z.string().optional(),
enum: z.array(z.string()),
enumNames: z.optional(z.array(z.string()))
enumNames: z.array(z.string()).optional(),
default: z.string().optional()
});
/**
* Union of all primitive schema definitions.
*/
export const PrimitiveSchemaDefinitionSchema = z.union([BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema, EnumSchemaSchema]);
export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]);
/**
* Parameters for an `elicitation/create` request.