Skip to main content
Glama
ttommyth

Interactive MCP

request_user_input

Clarify user requirements and confirm plans by prompting for input to resolve ambiguity before proceeding with actions.

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 main handler function for the 'request_user_input' tool. It extracts arguments, calls getCmdWindowInput to prompt the user, and returns the formatted response or handles timeout/empty input.
    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 (raw shape) defining the input parameters for the tool: projectName (required string), message (required string), predefinedOptions (optional array of strings).
    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 enabled, with dynamic description, imported schema, and inline handler.
    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 }] };
          }
        },
      );
    }
  • Core helper function that implements the cross-platform command window UI prompt: spawns a Node.js UI script via platform-specific commands, uses file watching and heartbeats for response collection, with timeout and cleanup logic.
    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 nodeBin = process.execPath;
              const nodeCommand = `exec "${nodeBin}" "${escapedScriptPath}" "${escapedSessionId}" "${tempDir}"; 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[] = [];
    
              // Fallback launcher using .command + open -a Terminal (handles Automation issues)
              const launchViaOpenCommand = async () => {
                try {
                  const launcherPath = path.join(
                    tempDir,
                    `interactive-mcp-launch-${sessionId}.command`,
                  );
                  const scriptContent = `#!/bin/bash\nexec "${nodeBin}" "${escapedScriptPath}" "${escapedSessionId}" "${tempDir}"\n`;
                  await fsPromises.writeFile(launcherPath, scriptContent, 'utf8');
                  await fsPromises.chmod(launcherPath, 0o755);
                  const openProc = spawn('open', ['-a', 'Terminal', launcherPath], {
                    stdio: ['ignore', 'ignore', 'ignore'],
                    detached: true,
                  });
                  openProc.unref();
                } catch (e) {
                  logger.error({ error: e }, 'Fallback open -a Terminal failed');
                }
              };
    
              ui = spawn(command, commandArgs, {
                stdio: ['ignore', 'ignore', 'ignore'],
                shell: true,
                detached: true,
              });
    
              // If AppleScript fails or exits non-zero, fallback to open -a Terminal
              ui.on('error', () => {
                void launchViaOpenCommand();
              });
              ui.on('close', (code: number | null) => {
                if (code !== null && code !== 0) {
                  void launchViaOpenCommand();
                }
              });
            } else if (platform === 'win32') {
              // Windows
              // Pass only the sessionId
              ui = spawn(process.execPath, [uiScriptPath, sessionId], {
                stdio: ['ignore', 'ignore', 'ignore'],
                shell: true,
                detached: true,
                windowsHide: false,
              });
            } else {
              // Linux or other
              // Pass only the sessionId
              ui = spawn(process.execPath, [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({ err: readError }, 'Error reading response file');
                    void cleanupAndResolve('');
                  }
                })();
              }
            });
    
            // 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 > 60000) {
                        // File never appeared and extended grace period (60s) passed, assume dead
                        logger.info(
                          `Heartbeat file ${heartbeatFilePath} never appeared within 60s. Process likely failed to start or was blocked by permissions.`,
                        );
                        void cleanupAndResolve('');
                      }
                      // 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({ error }, 'Heartbeat check error');
                      void cleanupAndResolve('');
                    }
                  } else {
                    // Handle cases where err is not an object with a code property
                    logger.error(
                      { error: err },
                      'Unexpected heartbeat check error',
                    );
                    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: setupError }, 'Error during cmd-input setup');
            // 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/ttommyth/interactive-mcp'

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