/**
* Choose Next Tool
*
* This tool allows MCP clients to present decision points in workflows such as
* requirements gathering, ideation, specification, and conceptual processes.
* It displays multiple options in a visually appealing, animated interface
* where users can select their preferred next step.
*/
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { ensureUIAvailable } from '../core/browser-bridge.js';
/**
* Tool definition for choose-next
*/
export const CHOOSE_NEXT_TOOL = {
name: 'choose-next',
description: 'Present decision points in workflows (requirements, ideation, specification, conceptual work) where you need human input to choose the next focus area or approach. Use this when you have multiple viable paths forward and need human judgment to decide which direction to pursue. The user will see options as visually appealing, animated boxes they can click to select. Perfect for: workflow decisions, requirement prioritization, feature selection, design direction choices, technical approach decisions, or any "what should we focus on next?" scenarios.',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Clear, concise title describing the decision point (e.g., "Choose Next Development Priority", "Select Design Approach", "Pick Focus Area")',
maxLength: 100,
},
description: {
type: 'string',
description: 'Detailed markdown description explaining WHY this decision is needed, the context, and what happens next. Use markdown formatting (headers, lists, bold text, etc.) to make it clear and readable. Explain the implications of the choice and how it affects the workflow.',
maxLength: 2000,
},
options: {
type: 'array',
description: 'Array of 2-8 options to choose from. Each option should be distinct and actionable.',
minItems: 2,
maxItems: 8,
items: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Unique identifier for this option (e.g., "feature-auth", "approach-microservices")',
},
title: {
type: 'string',
description: 'Short, compelling title for the option (e.g., "User Authentication", "Microservices Architecture")',
maxLength: 50,
},
description: {
type: 'string',
description: 'Clear explanation of what this option means, what it involves, and why someone might choose it. Be specific about outcomes and next steps.',
maxLength: 300,
},
icon: {
type: 'string',
description: 'Optional emoji or short icon text to make the option visually distinctive (e.g., "🔐", "🏗️", "📊")',
maxLength: 10,
},
},
required: ['id', 'title', 'description'],
additionalProperties: false,
},
},
},
required: ['title', 'description', 'options'],
additionalProperties: false,
},
} as const;
/**
* Input arguments for choose-next tool
*/
export interface ChooseNextArgs {
title: string;
description: string;
options: Array<{
id: string;
title: string;
description: string;
icon?: string;
}>;
}
/**
* Handler function for choose-next tool
*
* @param args - Tool arguments containing decision title, description, and options
* @param requestStorage - Map for storing pending requests
* @param notifyBrowser - Function to send notifications to browser UI
* @param extra - Optional extra parameters including abort signal
* @returns Promise that resolves with user's decision or rejects if cancelled/timeout
*/
export async function handleChooseNext(
args: ChooseNextArgs,
requestStorage: Map<string, any>,
notifyBrowser: (message: any) => void,
extra?: { signal?: AbortSignal }
): Promise<any> {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error('ChooseNext: Handling choose-next request:', args);
}
// Check if request was already cancelled before we start
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,
},
],
};
}
// Validate input
if (!args.title?.trim()) {
throw new McpError(ErrorCode.InvalidParams, 'Title is required and cannot be empty');
}
if (!args.description?.trim()) {
throw new McpError(ErrorCode.InvalidParams, 'Description is required and cannot be empty');
}
if (!Array.isArray(args.options) || args.options.length < 2) {
throw new McpError(ErrorCode.InvalidParams, 'At least 2 options are required');
}
if (args.options.length > 8) {
throw new McpError(ErrorCode.InvalidParams, 'Maximum 8 options allowed');
}
// Validate each option
for (const option of args.options) {
if (!option.id?.trim()) {
throw new McpError(ErrorCode.InvalidParams, 'Each option must have a non-empty id');
}
if (!option.title?.trim()) {
throw new McpError(ErrorCode.InvalidParams, 'Each option must have a non-empty title');
}
if (!option.description?.trim()) {
throw new McpError(ErrorCode.InvalidParams, 'Each option must have a non-empty description');
}
}
// Check for duplicate option IDs
const optionIds = args.options.map(opt => opt.id);
const uniqueIds = new Set(optionIds);
if (uniqueIds.size !== optionIds.length) {
throw new McpError(ErrorCode.InvalidParams, 'Option IDs must be unique');
}
const requestId = `choose-next-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Create the choose-next challenge
const challenge = {
id: `challenge-${Date.now()}`,
title: args.title,
description: args.description,
options: args.options.map(opt => ({
id: opt.id,
title: opt.title,
description: opt.description,
icon: opt.icon || undefined
}))
};
if (process.env.ASK_ME_MCP_DEBUG) {
console.error('ChooseNext: Created challenge:', challenge);
}
return new Promise((resolve, reject) => {
// Set up abort detection
const abortListener = () => {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error('ChooseNext: Request was aborted by client');
}
// Clean up
requestStorage.delete(requestId);
extra?.signal?.removeEventListener('abort', abortListener);
// Notify browser that request was cancelled
const message = 'The choose-next decision was cancelled by the client';
notifyBrowser({
type: 'request_cancelled',
data: { requestId, message }
});
reject(new McpError(ErrorCode.InvalidRequest, 'Request was cancelled by client'));
};
// Listen for abort signal
extra?.signal?.addEventListener('abort', abortListener);
// Set up timeout (5 minutes)
const timeoutMs = 5 * 60 * 1000;
const timeoutId = setTimeout(() => {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error('ChooseNext: Request timed out after 5 minutes');
}
// Clean up
requestStorage.delete(requestId);
extra?.signal?.removeEventListener('abort', abortListener);
// Notify browser of timeout
const message = 'The choose-next decision timed out after 5 minutes - no response received';
notifyBrowser({
type: 'request_timeout',
data: { requestId, message }
});
reject(new McpError(ErrorCode.InvalidRequest, 'Request timed out after 5 minutes'));
}, timeoutMs);
// Store request with resolver
requestStorage.set(requestId, {
type: 'choose-next',
challenge,
resolve: (response: any) => {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error('ChooseNext: Received response:', response);
}
// Clean up
clearTimeout(timeoutId);
requestStorage.delete(requestId);
extra?.signal?.removeEventListener('abort', abortListener);
// Process the response and provide appropriate client instructions
let responseText = '';
if (response.action === 'selected' && response.selectedOption) {
const option = response.selectedOption;
responseText += `✅ **User selected: "${option.title}"**\n\n`;
responseText += `**Selected Option Details:**\n`;
responseText += `- **Title:** ${option.title}\n`;
responseText += `- **Description:** ${option.description}\n`;
if (option.icon) {
responseText += `- **Icon:** ${option.icon}\n`;
}
responseText += `\n`;
if (response.message?.trim()) {
responseText += `**Additional User Message:** ${response.message}\n\n`;
}
responseText += `🎯 **INSTRUCTION for Client:** Proceed with implementing or focusing on "${option.title}". `;
responseText += `Use this selection to guide your next actions and decisions. The user has chosen this path forward.\n\n`;
} else if (response.action === 'abort') {
responseText += `🛑 **User ABORTED the decision process**\n\n`;
if (response.message?.trim()) {
responseText += `**User Message:** ${response.message}\n\n`;
}
responseText += `❌ **INSTRUCTION for Client:** The user does not want to proceed with any of the presented options. `;
responseText += `STOP the current workflow or approach. Consider asking for clarification about what the user `;
responseText += `wants to do instead, or step back to reassess the situation.\n\n`;
} else if (response.action === 'new-ideas') {
responseText += `💡 **User wants NEW IDEAS and alternatives**\n\n`;
if (response.message?.trim()) {
responseText += `**User Message:** ${response.message}\n\n`;
}
responseText += `🔄 **INSTRUCTION for Client:** The user is not satisfied with the presented options. `;
responseText += `Generate new, different, or alternative approaches. Consider:\n`;
responseText += `- Different perspectives or angles\n`;
responseText += `- Creative or unconventional solutions\n`;
responseText += `- Breaking down the decision into smaller parts\n`;
responseText += `- Exploring completely different directions\n`;
responseText += `Use ask-one-question, ask-multiple-choice, or another choose-next to present fresh ideas.\n\n`;
}
responseText += `**Original Decision Context:**\n`;
responseText += `- **Title:** ${args.title}\n`;
responseText += `- **Description:** ${args.description}\n`;
resolve({
content: [
{
type: 'text',
text: responseText.trim()
}
]
});
},
reject: (error: Error) => {
clearTimeout(timeoutId);
requestStorage.delete(requestId);
extra?.signal?.removeEventListener('abort', abortListener);
reject(error);
}
});
// Send request to browser UI
if (process.env.ASK_ME_MCP_DEBUG) {
console.error('ChooseNext: Sending request to browser UI');
}
notifyBrowser({
type: 'new_request',
data: {
id: requestId,
sessionId: 'demo',
question: args.title, // Use the challenge title as the question field
type: 'choose-next',
timestamp: new Date().toISOString(),
context: {
type: 'choose-next',
challenge
}
}
});
});
}