Skip to content

Commit d41bcf8

Browse files
committed
chore: add elicitRequestStream example for validation
1 parent 4ed61b6 commit d41bcf8

File tree

2 files changed

+157
-12
lines changed

2 files changed

+157
-12
lines changed

src/examples/client/simpleStreamableHttp.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from '../../types.js';
2525
import { getDisplayName } from '../../shared/metadataUtils.js';
2626
import { Ajv } from 'ajv';
27+
import { InMemoryTaskStore } from '../../experimental/tasks/stores/in-memory.js';
2728

2829
// Create readline interface for user input
2930
const readline = createInterface({
@@ -65,6 +66,7 @@ function printHelp(): void {
6566
console.log(' greet [name] - Call the greet tool');
6667
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
6768
console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)');
69+
console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation');
6870
console.log(' start-notifications [interval] [count] - Start periodic notifications');
6971
console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability');
7072
console.log(' list-prompts - List available prompts');
@@ -131,6 +133,10 @@ function commandLoop(): void {
131133
await callCollectInfoTool(args[1] || 'contact');
132134
break;
133135

136+
case 'collect-info-task':
137+
await callCollectInfoWithTask(args[1] || 'contact');
138+
break;
139+
134140
case 'start-notifications': {
135141
const interval = args[1] ? parseInt(args[1], 10) : 2000;
136142
const count = args[2] ? parseInt(args[2], 10) : 10;
@@ -232,7 +238,10 @@ async function connect(url?: string): Promise<void> {
232238
console.log(`Connecting to ${serverUrl}...`);
233239

234240
try {
235-
// Create a new client with form elicitation capability
241+
// Create task store for client-side task support
242+
const clientTaskStore = new InMemoryTaskStore();
243+
244+
// Create a new client with form elicitation capability and task support
236245
client = new Client(
237246
{
238247
name: 'example-client',
@@ -242,25 +251,46 @@ async function connect(url?: string): Promise<void> {
242251
capabilities: {
243252
elicitation: {
244253
form: {}
254+
},
255+
tasks: {
256+
requests: {
257+
elicitation: {
258+
create: {}
259+
}
260+
}
245261
}
246-
}
262+
},
263+
taskStore: clientTaskStore
247264
}
248265
);
249266
client.onerror = error => {
250267
console.error('\x1b[31mClient error:', error, '\x1b[0m');
251268
};
252269

253-
// Set up elicitation request handler with proper validation
254-
client.setRequestHandler(ElicitRequestSchema, async request => {
270+
// Set up elicitation request handler with proper validation and task support
271+
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
255272
if (request.params.mode !== 'form') {
256273
throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
257274
}
258275
console.log('\n🔔 Elicitation (form) Request Received:');
259276
console.log(`Message: ${request.params.message}`);
260277
console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`);
278+
console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`);
261279
console.log('Requested Schema:');
262280
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
263281

282+
// Helper to return result, optionally creating a task if requested
283+
const returnResult = async (result: { action: 'accept' | 'decline' | 'cancel'; content?: Record<string, unknown> }) => {
284+
if (request.params.task && extra.taskStore) {
285+
// Create a task and store the result
286+
const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl });
287+
await extra.taskStore.storeTaskResult(task.taskId, 'completed', result);
288+
console.log(`📋 Created client-side task: ${task.taskId}`);
289+
return { task };
290+
}
291+
return result;
292+
};
293+
264294
const schema = request.params.requestedSchema;
265295
const properties = schema.properties;
266296
const required = schema.required || [];
@@ -381,7 +411,7 @@ async function connect(url?: string): Promise<void> {
381411
}
382412

383413
if (inputCancelled) {
384-
return { action: 'cancel' };
414+
return returnResult({ action: 'cancel' });
385415
}
386416

387417
// If we didn't complete all fields due to an error, try again
@@ -394,7 +424,7 @@ async function connect(url?: string): Promise<void> {
394424
continue;
395425
} else {
396426
console.log('Maximum attempts reached. Declining request.');
397-
return { action: 'decline' };
427+
return returnResult({ action: 'decline' });
398428
}
399429
}
400430

@@ -412,7 +442,7 @@ async function connect(url?: string): Promise<void> {
412442
continue;
413443
} else {
414444
console.log('Maximum attempts reached. Declining request.');
415-
return { action: 'decline' };
445+
return returnResult({ action: 'decline' });
416446
}
417447
}
418448

@@ -427,24 +457,24 @@ async function connect(url?: string): Promise<void> {
427457
});
428458

429459
if (confirmAnswer === 'yes' || confirmAnswer === 'y') {
430-
return {
460+
return returnResult({
431461
action: 'accept',
432462
content
433-
};
463+
});
434464
} else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') {
435-
return { action: 'cancel' };
465+
return returnResult({ action: 'cancel' });
436466
} else if (confirmAnswer === 'no' || confirmAnswer === 'n') {
437467
if (attempts < maxAttempts) {
438468
console.log('Please re-enter the information...');
439469
continue;
440470
} else {
441-
return { action: 'decline' };
471+
return returnResult({ action: 'decline' });
442472
}
443473
}
444474
}
445475

446476
console.log('Maximum attempts reached. Declining request.');
447-
return { action: 'decline' };
477+
return returnResult({ action: 'decline' });
448478
});
449479

450480
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
@@ -641,6 +671,12 @@ async function callCollectInfoTool(infoType: string): Promise<void> {
641671
await callTool('collect-user-info', { infoType });
642672
}
643673

674+
async function callCollectInfoWithTask(infoType: string): Promise<void> {
675+
console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`);
676+
console.log('This will create a task on the server, which will elicit input and create a task on the client.\n');
677+
await callToolTask('collect-user-info-task', { infoType });
678+
}
679+
644680
async function startNotifications(interval: number, count: number): Promise<void> {
645681
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
646682
await callTool('start-notification-stream', { interval, count });

src/examples/server/simpleStreamableHttp.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../
77
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
88
import {
99
CallToolResult,
10+
ElicitResult,
1011
ElicitResultSchema,
1112
GetPromptResult,
1213
isInitializeRequest,
@@ -501,6 +502,114 @@ const getServer = () => {
501502
}
502503
);
503504

505+
// Register a tool that demonstrates bidirectional task support:
506+
// Server creates a task, then elicits input from client using elicitInputStream
507+
// Using the experimental tasks API - WARNING: may change without notice
508+
server.experimental.tasks.registerToolTask(
509+
'collect-user-info-task',
510+
{
511+
title: 'Collect Info with Task',
512+
description: 'Collects user info via elicitation with task support using elicitInputStream',
513+
inputSchema: {
514+
infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact')
515+
}
516+
},
517+
{
518+
async createTask({ infoType }, { taskStore, taskRequestedTtl }) {
519+
// Create the server-side task
520+
const task = await taskStore.createTask({
521+
ttl: taskRequestedTtl
522+
});
523+
524+
// Perform async work that makes a nested elicitation request using elicitInputStream
525+
(async () => {
526+
try {
527+
const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences';
528+
529+
// Define schemas with proper typing for PrimitiveSchemaDefinition
530+
const contactSchema: {
531+
type: 'object';
532+
properties: Record<string, PrimitiveSchemaDefinition>;
533+
required: string[];
534+
} = {
535+
type: 'object',
536+
properties: {
537+
name: { type: 'string', title: 'Full Name', description: 'Your full name' },
538+
email: { type: 'string', title: 'Email', description: 'Your email address' }
539+
},
540+
required: ['name', 'email']
541+
};
542+
543+
const preferencesSchema: {
544+
type: 'object';
545+
properties: Record<string, PrimitiveSchemaDefinition>;
546+
required: string[];
547+
} = {
548+
type: 'object',
549+
properties: {
550+
theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] },
551+
notifications: { type: 'boolean', title: 'Enable Notifications', default: true }
552+
},
553+
required: ['theme']
554+
};
555+
556+
const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema;
557+
558+
// Use elicitInputStream to elicit input from client
559+
// This demonstrates the streaming elicitation API
560+
// Access via server.server to get the underlying Server instance
561+
const stream = server.server.experimental.tasks.elicitInputStream({
562+
mode: 'form',
563+
message,
564+
requestedSchema
565+
});
566+
567+
let elicitResult: ElicitResult | undefined;
568+
for await (const msg of stream) {
569+
if (msg.type === 'result') {
570+
elicitResult = msg.result as ElicitResult;
571+
} else if (msg.type === 'error') {
572+
throw msg.error;
573+
}
574+
}
575+
576+
if (!elicitResult) {
577+
throw new Error('No result received from elicitation');
578+
}
579+
580+
let resultText: string;
581+
if (elicitResult.action === 'accept') {
582+
resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`;
583+
} else if (elicitResult.action === 'decline') {
584+
resultText = `User declined to provide ${infoType} information`;
585+
} else {
586+
resultText = 'User cancelled the request';
587+
}
588+
589+
await taskStore.storeTaskResult(task.taskId, 'completed', {
590+
content: [{ type: 'text', text: resultText }]
591+
});
592+
} catch (error) {
593+
console.error('Error in collect-user-info-task:', error);
594+
await taskStore.storeTaskResult(task.taskId, 'failed', {
595+
content: [{ type: 'text', text: `Error: ${error}` }],
596+
isError: true
597+
});
598+
}
599+
})();
600+
601+
return { task };
602+
},
603+
async getTask(_args, { taskId, taskStore }) {
604+
return await taskStore.getTask(taskId);
605+
},
606+
async getTaskResult(_args, { taskId, taskStore }) {
607+
const result = await taskStore.getTaskResult(taskId);
608+
return result as CallToolResult;
609+
}
610+
}
611+
);
612+
504613
return server;
505614
};
506615

0 commit comments

Comments
 (0)