Skip to content

Commit 04cbbfb

Browse files
ihrprbhosmer-ant
authored andcommitted
elicitation example
1 parent 77a284c commit 04cbbfb

File tree

8 files changed

+891
-2
lines changed

8 files changed

+891
-2
lines changed

src/client/index.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ListToolsRequestSchema,
1515
CallToolRequestSchema,
1616
CreateMessageRequestSchema,
17+
ElicitRequestSchema,
1718
ListRootsRequestSchema,
1819
ErrorCode,
1920
} from "../types.js";
@@ -597,6 +598,43 @@ test("should only allow setRequestHandler for declared capabilities", () => {
597598
}).toThrow("Client does not support roots capability");
598599
});
599600

601+
test("should allow setRequestHandler for declared elicitation capability", () => {
602+
const client = new Client(
603+
{
604+
name: "test-client",
605+
version: "1.0.0",
606+
},
607+
{
608+
capabilities: {
609+
elicitation: {},
610+
},
611+
},
612+
);
613+
614+
// This should work because elicitation is a declared capability
615+
expect(() => {
616+
client.setRequestHandler(ElicitRequestSchema, () => ({
617+
action: "accept",
618+
content: {
619+
username: "test-user",
620+
confirmed: true,
621+
},
622+
}));
623+
}).not.toThrow();
624+
625+
// This should throw because sampling is not a declared capability
626+
expect(() => {
627+
client.setRequestHandler(CreateMessageRequestSchema, () => ({
628+
model: "test-model",
629+
role: "assistant",
630+
content: {
631+
type: "text",
632+
text: "Test response",
633+
},
634+
}));
635+
}).toThrow("Client does not support sampling capability");
636+
});
637+
600638
/***
601639
* Test: Type Checking
602640
* Test that custom request/notification/result schemas can be used with the Client class.

src/client/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,14 @@ export class Client<
303303
}
304304
break;
305305

306+
case "elicitation/create":
307+
if (!this._capabilities.elicitation) {
308+
throw new Error(
309+
`Client does not support elicitation capability (required for ${method})`,
310+
);
311+
}
312+
break;
313+
306314
case "roots/list":
307315
if (!this._capabilities.roots) {
308316
throw new Error(

src/examples/client/simpleStreamableHttp.ts

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import {
1414
ListResourcesResultSchema,
1515
LoggingMessageNotificationSchema,
1616
ResourceListChangedNotificationSchema,
17+
ElicitRequestSchema,
1718
} from '../../types.js';
19+
import { Ajv } from 'ajv';
1820

1921
// Create readline interface for user input
2022
const readline = createInterface({
@@ -54,6 +56,7 @@ function printHelp(): void {
5456
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
5557
console.log(' greet [name] - Call the greet tool');
5658
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
59+
console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)');
5760
console.log(' start-notifications [interval] [count] - Start periodic notifications');
5861
console.log(' list-prompts - List available prompts');
5962
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
@@ -114,6 +117,10 @@ function commandLoop(): void {
114117
await callMultiGreetTool(args[1] || 'MCP User');
115118
break;
116119

120+
case 'collect-info':
121+
await callCollectInfoTool(args[1] || 'contact');
122+
break;
123+
117124
case 'start-notifications': {
118125
const interval = args[1] ? parseInt(args[1], 10) : 2000;
119126
const count = args[2] ? parseInt(args[2], 10) : 10;
@@ -183,15 +190,212 @@ async function connect(url?: string): Promise<void> {
183190
console.log(`Connecting to ${serverUrl}...`);
184191

185192
try {
186-
// Create a new client
193+
// Create a new client with elicitation capability
187194
client = new Client({
188195
name: 'example-client',
189196
version: '1.0.0'
197+
}, {
198+
capabilities: {
199+
elicitation: {},
200+
},
190201
});
191202
client.onerror = (error) => {
192203
console.error('\x1b[31mClient error:', error, '\x1b[0m');
193204
}
194205

206+
// Set up elicitation request handler with proper validation
207+
client.setRequestHandler(ElicitRequestSchema, async (request) => {
208+
console.log('\n🔔 Elicitation Request Received:');
209+
console.log(`Message: ${request.params.message}`);
210+
console.log('Requested Schema:');
211+
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
212+
213+
const schema = request.params.requestedSchema;
214+
const properties = schema.properties;
215+
const required = schema.required || [];
216+
217+
// Set up AJV validator for the requested schema
218+
const ajv = new Ajv({ strict: false, validateFormats: true });
219+
const validate = ajv.compile(schema);
220+
221+
let attempts = 0;
222+
const maxAttempts = 3;
223+
224+
while (attempts < maxAttempts) {
225+
attempts++;
226+
console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`);
227+
228+
const content: Record<string, unknown> = {};
229+
let inputCancelled = false;
230+
231+
// Collect input for each field
232+
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
233+
const field = fieldSchema as {
234+
type?: string;
235+
title?: string;
236+
description?: string;
237+
default?: unknown;
238+
enum?: string[];
239+
minimum?: number;
240+
maximum?: number;
241+
minLength?: number;
242+
maxLength?: number;
243+
format?: string;
244+
};
245+
246+
const isRequired = required.includes(fieldName);
247+
let prompt = `${field.title || fieldName}`;
248+
249+
// Add helpful information to the prompt
250+
if (field.description) {
251+
prompt += ` (${field.description})`;
252+
}
253+
if (field.enum) {
254+
prompt += ` [options: ${field.enum.join(', ')}]`;
255+
}
256+
if (field.type === 'number' || field.type === 'integer') {
257+
if (field.minimum !== undefined && field.maximum !== undefined) {
258+
prompt += ` [${field.minimum}-${field.maximum}]`;
259+
} else if (field.minimum !== undefined) {
260+
prompt += ` [min: ${field.minimum}]`;
261+
} else if (field.maximum !== undefined) {
262+
prompt += ` [max: ${field.maximum}]`;
263+
}
264+
}
265+
if (field.type === 'string' && field.format) {
266+
prompt += ` [format: ${field.format}]`;
267+
}
268+
if (isRequired) {
269+
prompt += ' *required*';
270+
}
271+
if (field.default !== undefined) {
272+
prompt += ` [default: ${field.default}]`;
273+
}
274+
275+
prompt += ': ';
276+
277+
const answer = await new Promise<string>((resolve) => {
278+
readline.question(prompt, (input) => {
279+
resolve(input.trim());
280+
});
281+
});
282+
283+
// Check for cancellation
284+
if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') {
285+
inputCancelled = true;
286+
break;
287+
}
288+
289+
// Parse and validate the input
290+
try {
291+
if (answer === '' && field.default !== undefined) {
292+
content[fieldName] = field.default;
293+
} else if (answer === '' && !isRequired) {
294+
// Skip optional empty fields
295+
continue;
296+
} else if (answer === '') {
297+
throw new Error(`${fieldName} is required`);
298+
} else {
299+
// Parse the value based on type
300+
let parsedValue: unknown;
301+
302+
if (field.type === 'boolean') {
303+
parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1';
304+
} else if (field.type === 'number') {
305+
parsedValue = parseFloat(answer);
306+
if (isNaN(parsedValue as number)) {
307+
throw new Error(`${fieldName} must be a valid number`);
308+
}
309+
} else if (field.type === 'integer') {
310+
parsedValue = parseInt(answer, 10);
311+
if (isNaN(parsedValue as number)) {
312+
throw new Error(`${fieldName} must be a valid integer`);
313+
}
314+
} else if (field.enum) {
315+
if (!field.enum.includes(answer)) {
316+
throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`);
317+
}
318+
parsedValue = answer;
319+
} else {
320+
parsedValue = answer;
321+
}
322+
323+
content[fieldName] = parsedValue;
324+
}
325+
} catch (error) {
326+
console.log(`❌ Error: ${error}`);
327+
// Continue to next attempt
328+
break;
329+
}
330+
}
331+
332+
if (inputCancelled) {
333+
return { action: 'cancel' };
334+
}
335+
336+
// If we didn't complete all fields due to an error, try again
337+
if (Object.keys(content).length !== Object.keys(properties).filter(name =>
338+
required.includes(name) || content[name] !== undefined
339+
).length) {
340+
if (attempts < maxAttempts) {
341+
console.log('Please try again...');
342+
continue;
343+
} else {
344+
console.log('Maximum attempts reached. Declining request.');
345+
return { action: 'decline' };
346+
}
347+
}
348+
349+
// Validate the complete object against the schema
350+
const isValid = validate(content);
351+
352+
if (!isValid) {
353+
console.log('❌ Validation errors:');
354+
validate.errors?.forEach(error => {
355+
console.log(` - ${error.instancePath || 'root'}: ${error.message}`);
356+
});
357+
358+
if (attempts < maxAttempts) {
359+
console.log('Please correct the errors and try again...');
360+
continue;
361+
} else {
362+
console.log('Maximum attempts reached. Declining request.');
363+
return { action: 'decline' };
364+
}
365+
}
366+
367+
// Show the collected data and ask for confirmation
368+
console.log('\n✅ Collected data:');
369+
console.log(JSON.stringify(content, null, 2));
370+
371+
const confirmAnswer = await new Promise<string>((resolve) => {
372+
readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => {
373+
resolve(input.trim().toLowerCase());
374+
});
375+
});
376+
377+
378+
if (confirmAnswer === 'yes' || confirmAnswer === 'y') {
379+
return {
380+
action: 'accept',
381+
content,
382+
};
383+
} else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') {
384+
return { action: 'cancel' };
385+
} else if (confirmAnswer === 'no' || confirmAnswer === 'n') {
386+
if (attempts < maxAttempts) {
387+
console.log('Please re-enter the information...');
388+
continue;
389+
} else {
390+
return { action: 'decline' };
391+
}
392+
}
393+
}
394+
395+
console.log('Maximum attempts reached. Declining request.');
396+
return { action: 'decline' };
397+
});
398+
195399
transport = new StreamableHTTPClientTransport(
196400
new URL(serverUrl),
197401
{
@@ -362,6 +566,11 @@ async function callMultiGreetTool(name: string): Promise<void> {
362566
await callTool('multi-greet', { name });
363567
}
364568

569+
async function callCollectInfoTool(infoType: string): Promise<void> {
570+
console.log(`Testing elicitation with collect-user-info tool (${infoType})...`);
571+
await callTool('collect-user-info', { infoType });
572+
}
573+
365574
async function startNotifications(interval: number, count: number): Promise<void> {
366575
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
367576
await callTool('start-notification-stream', { interval, count });

0 commit comments

Comments
 (0)