From d23e2dd51b30cda5a003cfd4961aa8b512e05a91 Mon Sep 17 00:00:00 2001 From: Prakhar Yadav Date: Sun, 5 Oct 2025 01:27:58 +0530 Subject: [PATCH] feat: add allowCustom option for enum fields to support custom values --- src/examples/client/simpleStreamableHttp.ts | 8 +- src/examples/server/simpleStreamableHttp.ts | 6 +- src/server/index.test.ts | 121 ++++++++++++++++++++ src/server/index.ts | 33 +++++- src/types.ts | 3 +- 5 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 10f6afcbe..c8576e87e 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -260,6 +260,7 @@ async function connect(url?: string): Promise { description?: string; default?: unknown; enum?: string[]; + allowCustom?: boolean; minimum?: number; maximum?: number; minLength?: number; @@ -276,6 +277,9 @@ async function connect(url?: string): Promise { } if (field.enum) { prompt += ` [options: ${field.enum.join(', ')}]`; + if (field.allowCustom) { + prompt += ' (custom allowed)'; + } } if (field.type === 'number' || field.type === 'integer') { if (field.minimum !== undefined && field.maximum !== undefined) { @@ -337,7 +341,9 @@ async function connect(url?: string): Promise { } } else if (field.enum) { if (!field.enum.includes(answer)) { - throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`); + if (!field.allowCustom) { + throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`); + } } parsedValue = answer; } else { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 5872cb4ac..32dd10d68 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -163,7 +163,8 @@ const getServer = () => { title: 'Theme', description: 'Choose your preferred theme', enum: ['light', 'dark', 'auto'], - enumNames: ['Light', 'Dark', 'Auto'] + enumNames: ['Light', 'Dark', 'Auto'], + allowCustom: true }, notifications: { type: 'boolean', @@ -176,7 +177,8 @@ const getServer = () => { title: 'Notification Frequency', description: 'How often would you like notifications?', enum: ['daily', 'weekly', 'monthly'], - enumNames: ['Daily', 'Weekly', 'Monthly'] + enumNames: ['Daily', 'Weekly', 'Monthly'], + allowCustom: true } }, required: ['theme'] diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d056707fe..bacf17a2e 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -424,6 +424,70 @@ test('should validate elicitation response against requested schema', async () = }); }); +test('should allow custom enum values when allowCustom is true', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + priority: 'urgent' + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Select a priority', + requestedSchema: { + type: 'object', + properties: { + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + allowCustom: true, + description: 'Choose from presets or enter a custom value' + } + }, + required: ['priority'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + priority: 'urgent' + } + }); +}); + test('should reject elicitation response with invalid data', async () => { const server = new Server( { @@ -493,6 +557,63 @@ test('should reject elicitation response with invalid data', async () => { ).rejects.toThrow(/does not match requested schema/); }); +test('should reject custom enum values when allowCustom is false', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + priority: 'urgent' + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Select a priority', + requestedSchema: { + type: 'object', + properties: { + priority: { + type: 'string', + enum: ['low', 'medium', 'high'] + } + }, + required: ['priority'] + } + }) + ).rejects.toThrow(/Elicitation response content does not match requested schema/); +}); + test('should allow elicitation reject and cancel without validation', async () => { const server = new Server( { diff --git a/src/server/index.ts b/src/server/index.ts index 3eb0ba0d4..e3f8aa92c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -277,6 +277,36 @@ export class Server< return this._capabilities; } + private prepareElicitationSchema(schema: ElicitRequest['params']['requestedSchema']): ElicitRequest['params']['requestedSchema'] { + const clonedSchema = JSON.parse(JSON.stringify(schema)) as ElicitRequest['params']['requestedSchema']; + + const properties = clonedSchema.properties ?? {}; + for (const propertySchema of Object.values(properties)) { + if (!propertySchema || typeof propertySchema !== 'object') { + continue; + } + + const enumValues = (propertySchema as { enum?: string[] }).enum; + const allowCustom = (propertySchema as { allowCustom?: boolean }).allowCustom; + + if (!allowCustom || !Array.isArray(enumValues) || enumValues.length === 0) { + continue; + } + + const propertyRecord = propertySchema as Record; + const customBranch = { ...propertyRecord }; + delete customBranch.enum; + delete customBranch.enumNames; + delete customBranch.allowCustom; + + propertyRecord.anyOf = [{ enum: enumValues }, customBranch]; + delete propertyRecord.enum; + delete propertyRecord.allowCustom; + } + + return clonedSchema; + } + async ping() { return this.request({ method: 'ping' }, EmptyResultSchema); } @@ -293,7 +323,8 @@ export class Server< try { const ajv = new Ajv(); - const validate = ajv.compile(params.requestedSchema); + const schemaForValidation = this.prepareElicitationSchema(params.requestedSchema); + const validate = ajv.compile(schemaForValidation); const isValid = validate(result.content); if (!isValid) { diff --git a/src/types.ts b/src/types.ts index e6d3fe46e..91b6e8618 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1224,7 +1224,8 @@ export const EnumSchemaSchema = z title: z.optional(z.string()), description: z.optional(z.string()), enum: z.array(z.string()), - enumNames: z.optional(z.array(z.string())) + enumNames: z.optional(z.array(z.string())), + allowCustom: z.optional(z.boolean()) }) .passthrough();