Skip to main content
Glama
mikeysrecipes

interactive-mcp

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
      });
    }

Schema Changelog

Changes observed during successful MCP inspections.

  1. First observed

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Without annotations, the description fully discloses behavior: pop-up display, return of user response or timeout after 60 seconds, context maintenance, graceful handling of empty responses, and formatting with project context. No contradictions.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is well-structured with sections but is lengthy (many sentences). Some redundancy between importantNotes and bestPractices (e.g., both emphasize frequent use). Could be tightened without losing clarity.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple 3-parameter tool with no output schema, the description is exceptionally complete: covers purpose, usage guidance, features, best practices, and examples. Leaves no gaps in understanding.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, so baseline 3. The description's parameters section adds context beyond schema: e.g., projectName is 'used in prompt formatting', predefinedOptions are optional. This adds meaningful value.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool sends a question to the user via a pop-up command prompt, with explicit purpose of clarifying requirements, confirming plans, or resolving ambiguity. It distinguishes from sibling tools like ask_intensive_chat by specifying a pop-up prompt rather than a chat message.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

A dedicated 'whenToUseThisTool' section provides exhaustive scenarios, and 'bestPractices' explicitly instructs not to use the tool when another tool can answer the question, offering clear alternatives. This provides excellent decision support.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.