Skip to content

Commit 0c52246

Browse files
Merge branch 'main' into add-sync-snippets-script
2 parents f301b69 + 91f6a27 commit 0c52246

File tree

5 files changed

+882
-17
lines changed

5 files changed

+882
-17
lines changed

examples/client/src/simpleStreamableHttp.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Client,
1515
getDisplayName,
1616
GetPromptResultSchema,
17+
InMemoryTaskStore,
1718
ListPromptsResultSchema,
1819
ListResourcesResultSchema,
1920
ListToolsResultSchema,
@@ -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');
@@ -140,6 +142,11 @@ function commandLoop(): void {
140142
break;
141143
}
142144

145+
case 'collect-info-task': {
146+
await callCollectInfoWithTask(args[1] || 'contact');
147+
break;
148+
}
149+
143150
case 'start-notifications': {
144151
const interval = args[1] ? Number.parseInt(args[1], 10) : 2000;
145152
const count = args[2] ? Number.parseInt(args[2], 10) : 10;
@@ -249,7 +256,10 @@ async function connect(url?: string): Promise<void> {
249256
console.log(`Connecting to ${serverUrl}...`);
250257

251258
try {
252-
// Create a new client with form elicitation capability
259+
// Create task store for client-side task support
260+
const clientTaskStore = new InMemoryTaskStore();
261+
262+
// Create a new client with form elicitation capability and task support
253263
client = new Client(
254264
{
255265
name: 'example-client',
@@ -259,25 +269,49 @@ async function connect(url?: string): Promise<void> {
259269
capabilities: {
260270
elicitation: {
261271
form: {}
272+
},
273+
tasks: {
274+
requests: {
275+
elicitation: {
276+
create: {}
277+
}
278+
}
262279
}
263-
}
280+
},
281+
taskStore: clientTaskStore
264282
}
265283
);
266284
client.onerror = error => {
267285
console.error('\u001B[31mClient error:', error, '\u001B[0m');
268286
};
269287

270-
// Set up elicitation request handler with proper validation
271-
client.setRequestHandler('elicitation/create', async request => {
288+
// Set up elicitation request handler with proper validation and task support
289+
client.setRequestHandler('elicitation/create', async (request, extra) => {
272290
if (request.params.mode !== 'form') {
273291
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
274292
}
275293
console.log('\n🔔 Elicitation (form) Request Received:');
276294
console.log(`Message: ${request.params.message}`);
277295
console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`);
296+
console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`);
278297
console.log('Requested Schema:');
279298
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
280299

300+
// Helper to return result, optionally creating a task if requested
301+
const returnResult = async (result: {
302+
action: 'accept' | 'decline' | 'cancel';
303+
content?: Record<string, string | number | boolean | string[]>;
304+
}) => {
305+
if (request.params.task && extra.task?.store) {
306+
// Create a task and store the result
307+
const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl });
308+
await extra.task.store.storeTaskResult(task.taskId, 'completed', result);
309+
console.log(`📋 Created client-side task: ${task.taskId}`);
310+
return { task };
311+
}
312+
return result;
313+
};
314+
281315
const schema = request.params.requestedSchema;
282316
const properties = schema.properties;
283317
const required = schema.required || [];
@@ -411,7 +445,7 @@ async function connect(url?: string): Promise<void> {
411445
}
412446

413447
if (inputCancelled) {
414-
return { action: 'cancel' };
448+
return returnResult({ action: 'cancel' });
415449
}
416450

417451
// If we didn't complete all fields due to an error, try again
@@ -424,7 +458,7 @@ async function connect(url?: string): Promise<void> {
424458
continue;
425459
} else {
426460
console.log('Maximum attempts reached. Declining request.');
427-
return { action: 'decline' };
461+
return returnResult({ action: 'decline' });
428462
}
429463
}
430464

@@ -443,7 +477,7 @@ async function connect(url?: string): Promise<void> {
443477
continue;
444478
} else {
445479
console.log('Maximum attempts reached. Declining request.');
446-
return { action: 'decline' };
480+
return returnResult({ action: 'decline' });
447481
}
448482
}
449483

@@ -460,22 +494,22 @@ async function connect(url?: string): Promise<void> {
460494
switch (confirmAnswer) {
461495
case 'yes':
462496
case 'y': {
463-
return {
497+
return returnResult({
464498
action: 'accept',
465499
content
466-
};
500+
});
467501
}
468502
case 'cancel':
469503
case 'c': {
470-
return { action: 'cancel' };
504+
return returnResult({ action: 'cancel' });
471505
}
472506
case 'no':
473507
case 'n': {
474508
if (attempts < maxAttempts) {
475509
console.log('Please re-enter the information...');
476510
continue;
477511
} else {
478-
return { action: 'decline' };
512+
return returnResult({ action: 'decline' });
479513
}
480514

481515
break;
@@ -485,7 +519,7 @@ async function connect(url?: string): Promise<void> {
485519
}
486520

487521
console.log('Maximum attempts reached. Declining request.');
488-
return { action: 'decline' };
522+
return returnResult({ action: 'decline' });
489523
});
490524

491525
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
@@ -699,6 +733,12 @@ async function callCollectInfoTool(infoType: string): Promise<void> {
699733
await callTool('collect-user-info', { infoType });
700734
}
701735

736+
async function callCollectInfoWithTask(infoType: string): Promise<void> {
737+
console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`);
738+
console.log('This will create a task on the server, which will elicit input and create a task on the client.\n');
739+
await callToolTask('collect-user-info-task', { infoType });
740+
}
741+
702742
async function startNotifications(interval: number, count: number): Promise<void> {
703743
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
704744
await callTool('start-notification-stream', { interval, count });

examples/server/src/simpleStreamableHttp.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express';
1010
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
1111
import type {
1212
CallToolResult,
13+
ElicitResult,
1314
GetPromptResult,
1415
PrimitiveSchemaDefinition,
1516
ReadResourceResult,
@@ -494,6 +495,114 @@ const getServer = () => {
494495
}
495496
);
496497

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

packages/core/src/types/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2629,8 +2629,8 @@ export type ResultTypeMap = {
26292629
'resources/unsubscribe': EmptyResult;
26302630
'tools/call': CallToolResult | CreateTaskResult;
26312631
'tools/list': ListToolsResult;
2632-
'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools;
2633-
'elicitation/create': ElicitResult;
2632+
'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult;
2633+
'elicitation/create': ElicitResult | CreateTaskResult;
26342634
'roots/list': ListRootsResult;
26352635
'tasks/get': GetTaskResult;
26362636
'tasks/result': Result;

0 commit comments

Comments
 (0)