Skip to content

Commit d23e2dd

Browse files
committed
feat: add allowCustom option for enum fields to support custom values
1 parent e0de082 commit d23e2dd

File tree

5 files changed

+166
-5
lines changed

5 files changed

+166
-5
lines changed

src/examples/client/simpleStreamableHttp.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ async function connect(url?: string): Promise<void> {
260260
description?: string;
261261
default?: unknown;
262262
enum?: string[];
263+
allowCustom?: boolean;
263264
minimum?: number;
264265
maximum?: number;
265266
minLength?: number;
@@ -276,6 +277,9 @@ async function connect(url?: string): Promise<void> {
276277
}
277278
if (field.enum) {
278279
prompt += ` [options: ${field.enum.join(', ')}]`;
280+
if (field.allowCustom) {
281+
prompt += ' (custom allowed)';
282+
}
279283
}
280284
if (field.type === 'number' || field.type === 'integer') {
281285
if (field.minimum !== undefined && field.maximum !== undefined) {
@@ -337,7 +341,9 @@ async function connect(url?: string): Promise<void> {
337341
}
338342
} else if (field.enum) {
339343
if (!field.enum.includes(answer)) {
340-
throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`);
344+
if (!field.allowCustom) {
345+
throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`);
346+
}
341347
}
342348
parsedValue = answer;
343349
} else {

src/examples/server/simpleStreamableHttp.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ const getServer = () => {
163163
title: 'Theme',
164164
description: 'Choose your preferred theme',
165165
enum: ['light', 'dark', 'auto'],
166-
enumNames: ['Light', 'Dark', 'Auto']
166+
enumNames: ['Light', 'Dark', 'Auto'],
167+
allowCustom: true
167168
},
168169
notifications: {
169170
type: 'boolean',
@@ -176,7 +177,8 @@ const getServer = () => {
176177
title: 'Notification Frequency',
177178
description: 'How often would you like notifications?',
178179
enum: ['daily', 'weekly', 'monthly'],
179-
enumNames: ['Daily', 'Weekly', 'Monthly']
180+
enumNames: ['Daily', 'Weekly', 'Monthly'],
181+
allowCustom: true
180182
}
181183
},
182184
required: ['theme']

src/server/index.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,70 @@ test('should validate elicitation response against requested schema', async () =
424424
});
425425
});
426426

427+
test('should allow custom enum values when allowCustom is true', async () => {
428+
const server = new Server(
429+
{
430+
name: 'test server',
431+
version: '1.0'
432+
},
433+
{
434+
capabilities: {
435+
prompts: {},
436+
resources: {},
437+
tools: {},
438+
logging: {}
439+
},
440+
enforceStrictCapabilities: true
441+
}
442+
);
443+
444+
const client = new Client(
445+
{
446+
name: 'test client',
447+
version: '1.0'
448+
},
449+
{
450+
capabilities: {
451+
elicitation: {}
452+
}
453+
}
454+
);
455+
456+
client.setRequestHandler(ElicitRequestSchema, () => ({
457+
action: 'accept',
458+
content: {
459+
priority: 'urgent'
460+
}
461+
}));
462+
463+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
464+
465+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
466+
467+
await expect(
468+
server.elicitInput({
469+
message: 'Select a priority',
470+
requestedSchema: {
471+
type: 'object',
472+
properties: {
473+
priority: {
474+
type: 'string',
475+
enum: ['low', 'medium', 'high'],
476+
allowCustom: true,
477+
description: 'Choose from presets or enter a custom value'
478+
}
479+
},
480+
required: ['priority']
481+
}
482+
})
483+
).resolves.toEqual({
484+
action: 'accept',
485+
content: {
486+
priority: 'urgent'
487+
}
488+
});
489+
});
490+
427491
test('should reject elicitation response with invalid data', async () => {
428492
const server = new Server(
429493
{
@@ -493,6 +557,63 @@ test('should reject elicitation response with invalid data', async () => {
493557
).rejects.toThrow(/does not match requested schema/);
494558
});
495559

560+
test('should reject custom enum values when allowCustom is false', async () => {
561+
const server = new Server(
562+
{
563+
name: 'test server',
564+
version: '1.0'
565+
},
566+
{
567+
capabilities: {
568+
prompts: {},
569+
resources: {},
570+
tools: {},
571+
logging: {}
572+
},
573+
enforceStrictCapabilities: true
574+
}
575+
);
576+
577+
const client = new Client(
578+
{
579+
name: 'test client',
580+
version: '1.0'
581+
},
582+
{
583+
capabilities: {
584+
elicitation: {}
585+
}
586+
}
587+
);
588+
589+
client.setRequestHandler(ElicitRequestSchema, () => ({
590+
action: 'accept',
591+
content: {
592+
priority: 'urgent'
593+
}
594+
}));
595+
596+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
597+
598+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
599+
600+
await expect(
601+
server.elicitInput({
602+
message: 'Select a priority',
603+
requestedSchema: {
604+
type: 'object',
605+
properties: {
606+
priority: {
607+
type: 'string',
608+
enum: ['low', 'medium', 'high']
609+
}
610+
},
611+
required: ['priority']
612+
}
613+
})
614+
).rejects.toThrow(/Elicitation response content does not match requested schema/);
615+
});
616+
496617
test('should allow elicitation reject and cancel without validation', async () => {
497618
const server = new Server(
498619
{

src/server/index.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,36 @@ export class Server<
277277
return this._capabilities;
278278
}
279279

280+
private prepareElicitationSchema(schema: ElicitRequest['params']['requestedSchema']): ElicitRequest['params']['requestedSchema'] {
281+
const clonedSchema = JSON.parse(JSON.stringify(schema)) as ElicitRequest['params']['requestedSchema'];
282+
283+
const properties = clonedSchema.properties ?? {};
284+
for (const propertySchema of Object.values(properties)) {
285+
if (!propertySchema || typeof propertySchema !== 'object') {
286+
continue;
287+
}
288+
289+
const enumValues = (propertySchema as { enum?: string[] }).enum;
290+
const allowCustom = (propertySchema as { allowCustom?: boolean }).allowCustom;
291+
292+
if (!allowCustom || !Array.isArray(enumValues) || enumValues.length === 0) {
293+
continue;
294+
}
295+
296+
const propertyRecord = propertySchema as Record<string, unknown>;
297+
const customBranch = { ...propertyRecord };
298+
delete customBranch.enum;
299+
delete customBranch.enumNames;
300+
delete customBranch.allowCustom;
301+
302+
propertyRecord.anyOf = [{ enum: enumValues }, customBranch];
303+
delete propertyRecord.enum;
304+
delete propertyRecord.allowCustom;
305+
}
306+
307+
return clonedSchema;
308+
}
309+
280310
async ping() {
281311
return this.request({ method: 'ping' }, EmptyResultSchema);
282312
}
@@ -293,7 +323,8 @@ export class Server<
293323
try {
294324
const ajv = new Ajv();
295325

296-
const validate = ajv.compile(params.requestedSchema);
326+
const schemaForValidation = this.prepareElicitationSchema(params.requestedSchema);
327+
const validate = ajv.compile(schemaForValidation);
297328
const isValid = validate(result.content);
298329

299330
if (!isValid) {

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1224,7 +1224,8 @@ export const EnumSchemaSchema = z
12241224
title: z.optional(z.string()),
12251225
description: z.optional(z.string()),
12261226
enum: z.array(z.string()),
1227-
enumNames: z.optional(z.array(z.string()))
1227+
enumNames: z.optional(z.array(z.string())),
1228+
allowCustom: z.optional(z.boolean())
12281229
})
12291230
.passthrough();
12301231

0 commit comments

Comments
 (0)