@@ -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
2022const 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+
365574async 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