Skip to content
Draft
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: 7 additions & 1 deletion src/examples/client/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ async function connect(url?: string): Promise<void> {
description?: string;
default?: unknown;
enum?: string[];
allowCustom?: boolean;
minimum?: number;
maximum?: number;
minLength?: number;
Expand All @@ -276,6 +277,9 @@ async function connect(url?: string): Promise<void> {
}
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) {
Expand Down Expand Up @@ -337,7 +341,9 @@ async function connect(url?: string): Promise<void> {
}
} 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 {
Expand Down
6 changes: 4 additions & 2 deletions src/examples/server/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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']
Expand Down
121 changes: 121 additions & 0 deletions src/server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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(
{
Expand Down
33 changes: 32 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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);
}
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down