/**
* Ask Multiple Choice Tool
*
* This tool allows MCP clients to request human input through multiple choice questions
* with selectable options, comments, and priority ratings.
*/
import { MultipleChoiceQuestion } from '@ask-me-mcp/askme-shared';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { ensureUIAvailable } from '../core/browser-bridge.js';
/**
* Tool definition for ask-multiple-choice
*/
export const ASK_MULTIPLE_CHOICE_TOOL = {
name: 'ask-multiple-choice',
description: 'Make structured decisions from a known set of options. Use when you KNOW the possible choices and need selection with priority/comments. Perfect for: feature selection, configuration choices, resource allocation. Choose this when you have 2+ specific options to pick from.',
inputSchema: {
type: 'object',
properties: {
questions: {
type: 'array',
description: 'Array of questions with multiple choice options',
items: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'The question text',
},
options: {
type: 'array',
description: 'Array of option texts',
items: {
type: 'string',
},
},
},
required: ['text', 'options'],
},
},
},
required: ['questions'],
},
} as const;
/**
* Input arguments for ask-multiple-choice tool
*/
export interface AskMultipleChoiceArgs {
questions: Array<{
text: string;
options: string[];
}>;
}
/**
* Handler function for ask-multiple-choice tool
*/
export async function handleAskMultipleChoice(
args: AskMultipleChoiceArgs,
requestStorage: Map<string, any>,
notifyBrowser: (message: any) => void,
extra?: { signal?: AbortSignal }
): Promise<any> {
const { questions } = args;
// Check if request was already cancelled
if (extra?.signal?.aborted) {
throw new McpError(ErrorCode.InvalidRequest, 'Request was cancelled');
}
// Ensure UI is available for user interaction
const uiMessage = await ensureUIAvailable();
if (uiMessage) {
// No UI available and couldn't open browser, return instructions to user
return {
content: [
{
type: 'text',
text: uiMessage,
},
],
};
}
const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Transform input format to internal format
const multipleChoiceQuestions: MultipleChoiceQuestion[] = questions.map((q, qIndex) => ({
id: `q-${qIndex}`,
text: q.text,
options: q.options.map((optionText, optIndex) => ({
id: `q-${qIndex}-opt-${optIndex}`,
text: optionText,
selected: false,
comment: undefined,
priority: undefined,
wontAnswer: false,
})),
}));
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[ask-multiple-choice] New request: ${requestId}`);
console.error(`[ask-multiple-choice] Questions: ${questions.length}`);
questions.forEach((q, i) => {
console.error(`[ask-multiple-choice] Q${i + 1}: ${q.text}`);
console.error(`[ask-multiple-choice] Options: ${q.options.join(', ')}`);
});
console.error(`[ask-multiple-choice] Please respond at http://localhost:4200`);
}
// Create a promise that will be resolved when the human responds
return new Promise((resolve, reject) => {
// Set up abort listener for client-side cancellation
const abortListener = () => {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[ask-multiple-choice] Request ${requestId} was cancelled by client`);
}
// Clean up pending request
if (requestStorage.has(requestId)) {
requestStorage.delete(requestId);
// Notify browser that request was cancelled
notifyBrowser({
type: 'request_cancelled',
data: {
requestId,
message: 'Request was cancelled by the client'
}
});
reject(new McpError(ErrorCode.InvalidRequest, 'Request was cancelled by client'));
}
};
// Add abort listener if signal is available
if (extra?.signal) {
extra.signal.addEventListener('abort', abortListener);
}
requestStorage.set(requestId, {
requestId,
resolve: (response) => {
// Format the response for the MCP client
let responseText = 'Multiple Choice Response:\n\n';
if (response.type === 'multiple-choice' && response.questions) {
response.questions.forEach((question: MultipleChoiceQuestion, qIndex: number) => {
responseText += `Question ${qIndex + 1}: "${question.text}"\n`;
// Check if any option has wontAnswer=true for this question
const hasWontAnswer = question.options.some(option => option.wontAnswer);
if (hasWontAnswer) {
responseText += `Status: Unanswered (User chose "Won't answer")\n`;
if (question.whyNotAnswering) {
responseText += `Reason: ${question.whyNotAnswering}\n`;
}
} else {
question.options.forEach((option) => {
const checkmark = option.selected ? '✓' : '✗';
responseText += `- ${checkmark} ${option.text}`;
// Add priority arrows if selected and has priority rating
if (option.selected && option.priority !== undefined) {
let priorityDisplay = '';
if (option.priority === -3) priorityDisplay = ' [Priority: ↓↓↓ (Lowest)]';
else if (option.priority === -2) priorityDisplay = ' [Priority: ↓↓ (Lower)]';
else if (option.priority === -1) priorityDisplay = ' [Priority: ↓ (Low)]';
else if (option.priority === 0) priorityDisplay = ' [Priority: → (Neutral)]';
else if (option.priority === 1) priorityDisplay = ' [Priority: ↑ (High)]';
else if (option.priority === 2) priorityDisplay = ' [Priority: ↑↑ (Higher)]';
else if (option.priority === 3) priorityDisplay = ' [Priority: ↑↑↑ (Highest)]';
responseText += priorityDisplay;
}
if (option.comment) {
responseText += ` (Comment: ${option.comment})`;
}
responseText += '\n';
});
}
responseText += '\n';
});
// Add completion status and instructions for client
if (response.completionStatus) {
responseText += '--- COMPLETION STATUS ---\n';
if (response.completionStatus === 'done') {
responseText += '✅ User indicated they are DONE with answering questions on this topic.\n';
responseText += 'INSTRUCTION: Do not ask additional questions. Proceed with implementation based on the answers provided.\n\n';
} else if (response.completionStatus === 'drill-deeper') {
responseText += '🔍 User wants to DRILL DEEPER with more questions on this topic.\n';
responseText += 'INSTRUCTION: Ask more detailed follow-up questions using the ask-multiple-choice tool to get more structured information on this topic.\n\n';
}
}
} else {
responseText += 'No response provided';
}
resolve({
content: [
{
type: 'text',
text: responseText,
},
],
});
},
reject
});
// Send the request to the browser via server-sent events
notifyBrowser({
type: 'new_request',
data: {
id: requestId,
question: `Multiple Choice: ${questions.length} question(s)`,
context: {
type: 'multiple-choice',
questions: multipleChoiceQuestions
},
timestamp: new Date(),
type: 'multiple-choice',
},
});
// Set a timeout to prevent indefinite waiting
const timeoutId = setTimeout(() => {
if (requestStorage.has(requestId)) {
requestStorage.delete(requestId);
// Clean up abort listener
if (extra?.signal) {
extra.signal.removeEventListener('abort', abortListener);
}
// Notify browser that request timed out
notifyBrowser({
type: 'request_timeout',
data: {
requestId,
message: 'Request timed out after 5 minutes - no response received'
}
});
reject(new Error('Request timeout'));
}
}, 300000); // 5 minutes timeout
// Override the resolve/reject in storage to clean up listeners
const originalStoredData = requestStorage.get(requestId);
if (originalStoredData) {
requestStorage.set(requestId, {
...originalStoredData,
resolve: (response: any) => {
clearTimeout(timeoutId);
if (extra?.signal) {
extra.signal.removeEventListener('abort', abortListener);
}
originalStoredData.resolve(response);
},
reject: (error: any) => {
clearTimeout(timeoutId);
if (extra?.signal) {
extra.signal.removeEventListener('abort', abortListener);
}
originalStoredData.reject(error);
}
});
}
});
}