request_user_input
Prompt users for input to clarify requirements, validate assumptions, or confirm actions before proceeding. Ensures accuracy and avoids errors by resolving uncertainties through direct user interaction.
Instructions
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| message | Yes | The specific question for the user (appears in the prompt) | |
| predefinedOptions | No | Predefined options for the user to choose from (optional) | |
| projectName | Yes | Identifies the context/project making the request (used in prompt formatting) |
Input Schema (JSON Schema)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"message": {
"description": "The specific question for the user (appears in the prompt)",
"type": "string"
},
"predefinedOptions": {
"description": "Predefined options for the user to choose from (optional)",
"items": {
"type": "string"
},
"type": "array"
},
"projectName": {
"description": "Identifies the context/project making the request (used in prompt formatting)",
"type": "string"
}
},
"required": [
"projectName",
"message"
],
"type": "object"
}
Implementation Reference
- src/index.ts:120-149 (handler)The main handler function for the 'request_user_input' tool. It destructures the arguments, calls the helper getCmdWindowInput to prompt the user, handles timeout and empty responses, and returns the appropriate content block.async (args) => { // Use inferred args type const { projectName, message, predefinedOptions } = args; const promptMessage = `${projectName}: ${message}`; const answer = await getCmdWindowInput( projectName, promptMessage, globalTimeoutSeconds, true, predefinedOptions, ); // Check for the specific timeout indicator if (answer === '__TIMEOUT__') { return { content: [ { type: 'text', text: 'User did not reply: Timeout occurred.' }, ], }; } // Empty string means user submitted empty input, non-empty is actual reply else if (answer === '') { return { content: [{ type: 'text', text: 'User replied with empty input.' }], }; } else { const reply = `User replied: ${answer}`; return { content: [{ type: 'text', text: reply }] }; } },
- Zod schema definition (raw shape) for the tool parameters: projectName, message, and optional predefinedOptions. This is used in the server.tool registration.const rawSchema: z.ZodRawShape = { projectName: z .string() .describe( 'Identifies the context/project making the request (used in prompt formatting)', ), message: z .string() .describe('The specific question for the user (appears in the prompt)'), predefinedOptions: z .array(z.string()) .optional() .describe('Predefined options for the user to choose from (optional)'), };
- src/index.ts:111-151 (registration)Registration of the 'request_user_input' tool on the MCP server using server.tool(), conditionally based on enablement. Includes dynamic description resolution and schema.if (isToolEnabled('request_user_input')) { // Use properties from the imported tool object server.tool( 'request_user_input', // Need to handle description potentially being a function typeof requestUserInputTool.description === 'function' ? requestUserInputTool.description(globalTimeoutSeconds) : requestUserInputTool.description, requestUserInputTool.schema, // Use schema property async (args) => { // Use inferred args type const { projectName, message, predefinedOptions } = args; const promptMessage = `${projectName}: ${message}`; const answer = await getCmdWindowInput( projectName, promptMessage, globalTimeoutSeconds, true, predefinedOptions, ); // Check for the specific timeout indicator if (answer === '__TIMEOUT__') { return { content: [ { type: 'text', text: 'User did not reply: Timeout occurred.' }, ], }; } // Empty string means user submitted empty input, non-empty is actual reply else if (answer === '') { return { content: [{ type: 'text', text: 'User replied with empty input.' }], }; } else { const reply = `User replied: ${answer}`; return { content: [{ type: 'text', text: reply }] }; } }, ); }
- src/commands/input/index.ts:38-287 (helper)Core helper function that implements the user input prompt via spawning a platform-specific UI process (Terminal on macOS, cmd on Windows/Linux), using temp files for communication, heartbeat for liveness, and timeout handling. Called by the tool handler.export async function getCmdWindowInput( projectName: string, promptMessage: string, timeoutSeconds: number = USER_INPUT_TIMEOUT_SECONDS, // Use constant as default showCountdown: boolean = true, predefinedOptions?: string[], ): Promise<string> { // Create a temporary file for the detached process to write to const sessionId = crypto.randomBytes(8).toString('hex'); const tempDir = os.tmpdir(); const tempFilePath = path.join(tempDir, `cmd-ui-response-${sessionId}.txt`); const heartbeatFilePath = path.join( tempDir, `cmd-ui-heartbeat-${sessionId}.txt`, ); const optionsFilePath = path.join( tempDir, `cmd-ui-options-${sessionId}.json`, ); // New options file path return new Promise<string>((resolve) => { // Wrap the async setup logic in an IIFE void (async () => { // Path to the UI script (will be in the same directory after compilation) const uiScriptPath = path.join(__dirname, 'ui.js'); // Gather options const options = { projectName, prompt: promptMessage, timeout: timeoutSeconds, showCountdown, sessionId, outputFile: tempFilePath, heartbeatFile: heartbeatFilePath, // Pass heartbeat file path too predefinedOptions, }; let ui; // Moved setup into try block try { // Write options to the file before spawning await fsPromises.writeFile( optionsFilePath, JSON.stringify(options), 'utf8', ); // Platform-specific spawning const platform = os.platform(); if (platform === 'darwin') { // macOS const escapedScriptPath = uiScriptPath; const escapedSessionId = sessionId; // Only need sessionId now // Construct the command string directly for the shell. Quotes handle paths with spaces. // Pass only the sessionId const nodeCommand = `exec node "${escapedScriptPath}" "${escapedSessionId}"; exit 0`; // Escape the node command for osascript's AppleScript string: const escapedNodeCommand = nodeCommand .replace(/\\/g, '\\\\') // Escape backslashes .replace(/"/g, '\\"'); // Escape double quotes // Activate Terminal first, then do script with exec const command = `osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapedNodeCommand}"'`; const commandArgs: string[] = []; ui = spawn(command, commandArgs, { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }); } else if (platform === 'win32') { // Windows // Pass only the sessionId ui = spawn('node', [uiScriptPath, sessionId], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, windowsHide: false, }); } else { // Linux or other // Pass only the sessionId ui = spawn('node', [uiScriptPath, sessionId], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }); } let watcher: FSWatcher | null = null; let timeoutHandle: NodeJS.Timeout | null = null; let heartbeatInterval: NodeJS.Timeout | null = null; let heartbeatFileSeen = false; // Track if we've ever seen the heartbeat file const startTime = Date.now(); // Record start time for initial grace period // Define cleanupAndResolve inside the promise scope const cleanupAndResolve = async (response: string) => { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (watcher) { watcher.close(); watcher = null; } if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } // Pass optionsFilePath to cleanupResources await cleanupResources( heartbeatFilePath, tempFilePath, optionsFilePath, ); resolve(response); }; // Listen for process exit events - moved definition before IIFE start const handleExit = (code?: number | null) => { // If the process exited with a non-zero code and watcher/timeout still exist if (code !== 0 && (watcher || timeoutHandle)) { void cleanupAndResolve(''); } }; const handleError = () => { if (watcher || timeoutHandle) { // Only cleanup if not already cleaned up void cleanupAndResolve(''); } }; ui.on('exit', handleExit); ui.on('error', handleError); // Unref the child process so the parent can exit independently ui.unref(); // Create an empty temp file before watching for user response await fsPromises.writeFile(tempFilePath, '', 'utf8'); // Use renamed import // Wait briefly for the heartbeat file to potentially be created await new Promise((res) => setTimeout(res, 500)); // Watch for content being written to the temp file watcher = watch(tempFilePath, (eventType: string) => { // Removed async if (eventType === 'change') { // Read the response and cleanup // Use an async IIFE inside the non-async callback void (async () => { try { const data = await fsPromises.readFile(tempFilePath, 'utf8'); // Use renamed import if (data) { const response = data.trim(); void cleanupAndResolve(response); // Mark promise as intentionally ignored } } catch (readError) { logger.error('Error reading response file:', readError); void cleanupAndResolve(''); // Cleanup on read error } })(); } }); // Start heartbeat check interval heartbeatInterval = setInterval(() => { // Removed async // Use an async IIFE inside the non-async callback void (async () => { try { const stats = await fsPromises.stat(heartbeatFilePath); // Use renamed import const now = Date.now(); // If file hasn't been modified in the last 3 seconds, assume dead if (now - stats.mtime.getTime() > 3000) { logger.info( `Heartbeat file ${heartbeatFilePath} hasn't been updated recently. Process likely exited.`, // Added logger info ); void cleanupAndResolve(''); // Mark promise as intentionally ignored } else { heartbeatFileSeen = true; // Mark that we've seen the file } } catch (err: unknown) { // Type err as unknown // Check if err is an error object with a code property if (err && typeof err === 'object' && 'code' in err) { const error = err as { code: string }; // Type assertion if (error.code === 'ENOENT') { // File not found if (heartbeatFileSeen) { // File existed before but is now gone, assume dead logger.info( `Heartbeat file ${heartbeatFilePath} not found after being seen. Process likely exited.`, // Added logger info ); void cleanupAndResolve(''); // Mark promise as intentionally ignored } else if (Date.now() - startTime > 7000) { // File never appeared and initial grace period (7s) passed, assume dead logger.info( `Heartbeat file ${heartbeatFilePath} never appeared. Process likely failed to start.`, // Added logger info ); void cleanupAndResolve(''); // Mark promise as intentionally ignored } // Otherwise, file just hasn't appeared yet, wait longer } else { // Removed check for !== 'ENOENT' as it's implied // Log other errors and resolve logger.error('Heartbeat check error:', error); void cleanupAndResolve(''); // Resolve immediately on other errors? Marked promise as intentionally ignored } } else { // Handle cases where err is not an object with a code property logger.error('Unexpected heartbeat check error:', err); void cleanupAndResolve(''); // Mark promise as intentionally ignored } } })(); }, 1500); // Check every 1.5 seconds // Timeout to stop watching if no response within limit timeoutHandle = setTimeout( () => { logger.info( `Input timeout reached after ${timeoutSeconds} seconds.`, ); // Added logger info void cleanupAndResolve(''); // Mark promise as intentionally ignored }, timeoutSeconds * 1000 + 5000, ); // Add a bit more buffer } catch (setupError) { logger.error('Error during cmd-input setup:', setupError); // Ensure cleanup happens even if setup fails // Pass optionsFilePath to cleanupResources await cleanupResources( heartbeatFilePath, tempFilePath, optionsFilePath, ); resolve(''); // Resolve with empty string after attempting cleanup } })(); // Execute the IIFE }); }
- JSON Schema-like parameters definition in the capabilityInfo, used for MCP tool capabilities declaration.const capabilityInfo: ToolCapabilityInfo = { description: 'Send a question to the user via a pop-up command prompt and await their reply.', parameters: { type: 'object', properties: { projectName: { type: 'string', description: 'Identifies the context/project making the request (used in prompt formatting)', }, message: { type: 'string', description: 'The specific question for the user (appears in the prompt)', }, predefinedOptions: { type: 'array', items: { type: 'string' }, optional: true, // Mark as optional here too for consistency description: 'Predefined options for the user to choose from (optional)', }, }, required: ['projectName', 'message'], }, };