Skip to content

elicitation example #520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
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
38 changes: 38 additions & 0 deletions src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ListToolsRequestSchema,
CallToolRequestSchema,
CreateMessageRequestSchema,
ElicitRequestSchema,
ListRootsRequestSchema,
ErrorCode,
} from "../types.js";
Expand Down Expand Up @@ -597,6 +598,43 @@ test("should only allow setRequestHandler for declared capabilities", () => {
}).toThrow("Client does not support roots capability");
});

test("should allow setRequestHandler for declared elicitation capability", () => {
const client = new Client(
{
name: "test-client",
version: "1.0.0",
},
{
capabilities: {
elicitation: {},
},
},
);

// This should work because elicitation is a declared capability
expect(() => {
client.setRequestHandler(ElicitRequestSchema, () => ({
action: "accept",
content: {
username: "test-user",
confirmed: true,
},
}));
}).not.toThrow();

// This should throw because sampling is not a declared capability
expect(() => {
client.setRequestHandler(CreateMessageRequestSchema, () => ({
model: "test-model",
role: "assistant",
content: {
type: "text",
text: "Test response",
},
}));
}).toThrow("Client does not support sampling capability");
});

/***
* Test: Type Checking
* Test that custom request/notification/result schemas can be used with the Client class.
Expand Down
8 changes: 8 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,14 @@ export class Client<
}
break;

case "elicitation/create":
if (!this._capabilities.elicitation) {
throw new Error(
`Client does not support elicitation capability (required for ${method})`,
);
}
break;

case "roots/list":
if (!this._capabilities.roots) {
throw new Error(
Expand Down
211 changes: 210 additions & 1 deletion src/examples/client/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
ListResourcesResultSchema,
LoggingMessageNotificationSchema,
ResourceListChangedNotificationSchema,
ElicitRequestSchema,
} from '../../types.js';
import Ajv from "ajv";

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

case 'collect-info':
await callCollectInfoTool(args[1] || 'contact');
break;

case 'start-notifications': {
const interval = args[1] ? parseInt(args[1], 10) : 2000;
const count = args[2] ? parseInt(args[2], 10) : 10;
Expand Down Expand Up @@ -183,15 +190,212 @@ async function connect(url?: string): Promise<void> {
console.log(`Connecting to ${serverUrl}...`);

try {
// Create a new client
// Create a new client with elicitation capability
client = new Client({
name: 'example-client',
version: '1.0.0'
}, {
capabilities: {
elicitation: {},
},
});
client.onerror = (error) => {
console.error('\x1b[31mClient error:', error, '\x1b[0m');
}

// Set up elicitation request handler with proper validation
client.setRequestHandler(ElicitRequestSchema, async (request) => {
console.log('\n🔔 Elicitation Request Received:');
console.log(`Message: ${request.params.message}`);
console.log('Requested Schema:');
console.log(JSON.stringify(request.params.requestedSchema, null, 2));

const schema = request.params.requestedSchema;
const properties = schema.properties;
const required = schema.required || [];

// Set up AJV validator for the requested schema
const ajv = new Ajv();
const validate = ajv.compile(schema);

let attempts = 0;
const maxAttempts = 3;

while (attempts < maxAttempts) {
attempts++;
console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`);

const content: Record<string, unknown> = {};
let inputCancelled = false;

// Collect input for each field
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
const field = fieldSchema as {
type?: string;
title?: string;
description?: string;
default?: unknown;
enum?: string[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
format?: string;
};

const isRequired = required.includes(fieldName);
let prompt = `${field.title || fieldName}`;

// Add helpful information to the prompt
if (field.description) {
prompt += ` (${field.description})`;
}
if (field.enum) {
prompt += ` [options: ${field.enum.join(', ')}]`;
}
if (field.type === 'number' || field.type === 'integer') {
if (field.minimum !== undefined && field.maximum !== undefined) {
prompt += ` [${field.minimum}-${field.maximum}]`;
} else if (field.minimum !== undefined) {
prompt += ` [min: ${field.minimum}]`;
} else if (field.maximum !== undefined) {
prompt += ` [max: ${field.maximum}]`;
}
}
if (field.type === 'string' && field.format) {
prompt += ` [format: ${field.format}]`;
}
if (isRequired) {
prompt += ' *required*';
}
if (field.default !== undefined) {
prompt += ` [default: ${field.default}]`;
}

prompt += ': ';

const answer = await new Promise<string>((resolve) => {
readline.question(prompt, (input) => {
resolve(input.trim());
});
});

// Check for cancellation
if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') {
inputCancelled = true;
break;
}

// Parse and validate the input
try {
if (answer === '' && field.default !== undefined) {
content[fieldName] = field.default;
} else if (answer === '' && !isRequired) {
// Skip optional empty fields
continue;
} else if (answer === '') {
throw new Error(`${fieldName} is required`);
} else {
// Parse the value based on type
let parsedValue: unknown;

if (field.type === 'boolean') {
parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1';
} else if (field.type === 'number') {
parsedValue = parseFloat(answer);
if (isNaN(parsedValue as number)) {
throw new Error(`${fieldName} must be a valid number`);
}
} else if (field.type === 'integer') {
parsedValue = parseInt(answer, 10);
if (isNaN(parsedValue as number)) {
throw new Error(`${fieldName} must be a valid integer`);
}
} else if (field.enum) {
if (!field.enum.includes(answer)) {
throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`);
}
parsedValue = answer;
} else {
parsedValue = answer;
}

content[fieldName] = parsedValue;
}
} catch (error) {
console.log(`❌ Error: ${error}`);
// Continue to next attempt
break;
}
}

if (inputCancelled) {
return { action: 'cancel' };
}

// If we didn't complete all fields due to an error, try again
if (Object.keys(content).length !== Object.keys(properties).filter(name =>
required.includes(name) || content[name] !== undefined
).length) {
if (attempts < maxAttempts) {
console.log('Please try again...');
continue;
} else {
console.log('Maximum attempts reached. Declining request.');
return { action: 'decline' };
}
}

// Validate the complete object against the schema
const isValid = validate(content);

if (!isValid) {
console.log('❌ Validation errors:');
validate.errors?.forEach(error => {
console.log(` - ${error.dataPath || 'root'}: ${error.message}`);
});

if (attempts < maxAttempts) {
console.log('Please correct the errors and try again...');
continue;
} else {
console.log('Maximum attempts reached. Declining request.');
return { action: 'decline' };
}
}

// Show the collected data and ask for confirmation
console.log('\n✅ Collected data:');
console.log(JSON.stringify(content, null, 2));

const confirmAnswer = await new Promise<string>((resolve) => {
readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => {
resolve(input.trim().toLowerCase());
});
});


if (confirmAnswer === 'yes' || confirmAnswer === 'y') {
return {
action: 'accept',
content,
};
} else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') {
return { action: 'cancel' };
} else if (confirmAnswer === 'no' || confirmAnswer === 'n') {
if (attempts < maxAttempts) {
console.log('Please re-enter the information...');
continue;
} else {
return { action: 'decline' };
}
}
}

console.log('Maximum attempts reached. Declining request.');
return { action: 'decline' };
});

transport = new StreamableHTTPClientTransport(
new URL(serverUrl),
{
Expand Down Expand Up @@ -362,6 +566,11 @@ async function callMultiGreetTool(name: string): Promise<void> {
await callTool('multi-greet', { name });
}

async function callCollectInfoTool(infoType: string): Promise<void> {
console.log(`Testing elicitation with collect-user-info tool (${infoType})...`);
await callTool('collect-user-info', { infoType });
}

async function startNotifications(interval: number, count: number): Promise<void> {
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
await callTool('start-notification-stream', { interval, count });
Expand Down
Loading