Skip to main content
Glama

request_user_input

Ask users questions to clarify requirements, confirm plans, or resolve ambiguity before proceeding with actions like code edits or file operations.

Instructions

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
projectNameYesIdentifies the context/project making the request (used in prompt formatting)
messageYesThe specific question for the user (appears in the prompt)
predefinedOptionsNoPredefined options for the user to choose from (optional)

Implementation Reference

  • The asynchronous handler function for the 'request_user_input' tool. It extracts arguments, constructs the prompt, calls getCmdWindowInput, and returns the formatted user response or timeout/empty message.
    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/index.ts:111-151 (registration)
    Conditional registration of the 'request_user_input' tool using server.tool(), including dynamic description resolution and schema from the tool definition.
    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 }] }; } }, ); }
  • Zod raw schema shape for the tool's input parameters: projectName, message, and optional predefinedOptions.
    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)'), };
  • Core helper function that implements the user input prompting by spawning a detached Node.js UI process, using temporary files for communication, heartbeat checks for process liveness, and handling timeouts across platforms.
    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 }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mikeysrecipes/interactive-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server