/**
* Ask One Question Tool
*
* This tool allows MCP clients to request human input by asking a single question
* through the web interface.
*/
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { ensureUIAvailable } from '../core/browser-bridge.js';
/**
* Tool definition for ask-one-question
*/
export const ASK_ONE_QUESTION_TOOL = {
name: 'ask-one-question',
description: 'Get free-form text responses when a situation is very unclear and cannot be solved with the other tools. Use ONLY when facing total uncertainty where you don\'t know what you don\'t know. Choose this when the situation is too unclear for structured options (ask-multiple-choice) or specific claims to validate (challenge-hypothesis). Write your question in markdown format with proper formatting, headers, lists, code blocks, etc. to make it clear and readable for the human.',
inputSchema: {
type: 'object',
properties: {
question: {
type: 'string',
description: 'The question or request for the human. Write this in markdown format with proper formatting, headers, lists, code blocks, links, etc. to make it clear and readable. Use markdown syntax like # for headers, ** for bold, * for lists, ``` for code blocks, etc.',
},
context: {
type: 'object',
description: 'Optional context object with any relevant information',
additionalProperties: true,
},
options: {
type: 'array',
description: 'Optional array of predefined response options',
items: {
type: 'string',
},
},
},
required: ['question'],
},
} as const;
/**
* Input arguments for ask-one-question tool
*/
export interface AskOneQuestionArgs {
question: string;
context?: Record<string, unknown>;
options?: string[];
}
/**
* Handler function for ask-one-question tool
*/
export async function handleAskOneQuestion(
args: AskOneQuestionArgs,
requestStorage: Map<string, any>,
notifyBrowser: (message: any) => void,
extra?: { signal?: AbortSignal }
): Promise<any> {
const { question, context, options } = 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)}`;
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[ask-one-question] New request: ${requestId}`);
console.error(`[ask-one-question] Question: ${question}`);
if (options) {
console.error(`[ask-one-question] Options: ${options.join(', ')}`);
}
console.error(`[ask-one-question] Please respond at http://localhost:4200`);
}
// Create a promise that will be resolved when the human responds
return new Promise((resolve, reject) => {
requestStorage.set(requestId, { requestId, resolve, reject });
// Set up abort listener for client-side cancellation
const abortListener = () => {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[ask-one-question] 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);
}
// Send the request to the browser via server-sent events
notifyBrowser({
type: 'new_request',
data: {
id: requestId,
question,
context: context || (options ? { options } : undefined),
timestamp: new Date(),
type: 'single-question',
},
});
// 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);
}
resolve(response);
},
reject: (error: any) => {
clearTimeout(timeoutId);
if (extra?.signal) {
extra.signal.removeEventListener('abort', abortListener);
}
reject(error);
}
});
}
});
}