Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
native-dialog.ts12.5 kB
/** * Native OS Dialog Fallback * * Provides native OS dialog boxes for when MCP elicitation is not supported. * Works independently of the MCP stdio protocol. * * Timeout Strategy: * - Short timeout (45s) for AI responsiveness * - Cached responses for retry support * - Clear user feedback on timeout */ import { exec } from 'child_process'; import { promisify } from 'util'; import { platform } from 'os'; import { logger } from './logger.js'; const execAsync = promisify(exec); export interface DialogOptions { title: string; message: string; buttons?: string[]; // e.g., ['Approve', 'Cancel'] defaultButton?: number; // 0-based index icon?: 'warning' | 'info' | 'error' | 'question'; timeoutSeconds?: number; // Custom timeout (default: 45 seconds) } /** * Pending dialog state for retry support */ interface PendingDialog { promise: Promise<DialogResult>; startTime: number; options: DialogOptions; } /** * Cache of pending dialogs by content hash * Allows retry mechanism when AI times out before user responds */ const pendingDialogs = new Map<string, PendingDialog>(); /** * Cache of completed dialog results * Kept for 60 seconds to support retry after user responds */ const completedDialogs = new Map<string, { result: DialogResult; timestamp: number }>(); /** * Generate a hash for dialog content to enable retry lookups */ function getDialogHash(options: DialogOptions): string { return `${options.title}:${options.message}:${options.buttons?.join(',')}`; } export interface DialogResult { button: string; // Button text that was clicked cancelled: boolean; // True if user cancelled/closed dialog timedOut?: boolean; // True if dialog timed out waiting for user stillPending?: boolean; // True if dialog is still waiting (for retry) } /** * Show a native OS dialog box with retry support * * Falls back through different methods based on OS: * - macOS: AppleScript display dialog * - Windows: PowerShell dialogs * - Linux: zenity or kdialog * * Retry Mechanism: * 1. First call: Shows dialog with short timeout (45s), caches promise * 2. If timeout before user responds: Returns timedOut=true, stillPending=true * 3. Retry call: Checks cache, returns result if user already responded * * @param options Dialog configuration * @returns Which button was clicked and whether user cancelled/timed out */ export async function showNativeDialog(options: DialogOptions): Promise<DialogResult> { const hash = getDialogHash(options); const now = Date.now(); // Clean up old completed dialogs (older than 60 seconds) for (const [key, value] of completedDialogs.entries()) { if (now - value.timestamp > 60000) { completedDialogs.delete(key); } } // Check if we already have a completed result (retry after user responded) const completed = completedDialogs.get(hash); if (completed) { logger.info('Returning cached dialog result from retry'); completedDialogs.delete(hash); // Clear after use return completed.result; } // Check if dialog is already pending (concurrent call) let pending = pendingDialogs.get(hash); if (!pending) { // Start new dialog const os = platform(); const dialogPromise = (async () => { try { let result: DialogResult; switch (os) { case 'darwin': result = await showMacDialog(options); break; case 'win32': result = await showWindowsDialog(options); break; case 'linux': result = await showLinuxDialog(options); break; default: throw new Error(`Unsupported platform: ${os}`); } // Cache the result for retry support completedDialogs.set(hash, { result, timestamp: Date.now() }); pendingDialogs.delete(hash); return result; } catch (error: any) { pendingDialogs.delete(hash); logger.error(`Failed to show native dialog: ${error.message}`); throw error; } })(); pending = { promise: dialogPromise, startTime: now, options }; pendingDialogs.set(hash, pending); } // Wait for result with our own timeout check const timeoutMs = (options.timeoutSeconds || 45) * 1000; const elapsed = now - pending.startTime; const remainingTime = Math.max(0, timeoutMs - elapsed); if (remainingTime === 0) { // Already timed out return { button: 'Cancel', cancelled: true, timedOut: true, stillPending: false // Dialog already closed }; } try { // Race between dialog result and our timeout const result = await Promise.race([ pending.promise, new Promise<DialogResult>((_, reject) => setTimeout(() => reject(new Error('DIALOG_TIMEOUT')), remainingTime) ) ]); return result; } catch (error: any) { if (error.message === 'DIALOG_TIMEOUT') { // Our timeout hit, but dialog might still be open logger.warn(`Dialog timed out after ${Math.round(elapsed / 1000)}s, but may still be waiting for user`); return { button: 'Cancel', cancelled: true, timedOut: true, stillPending: true // Dialog is still open, user can still respond }; } throw error; } } /** * Show macOS dialog using AppleScript */ async function showMacDialog(options: DialogOptions): Promise<DialogResult> { const buttons = options.buttons || ['OK', 'Cancel']; const defaultButton = options.defaultButton !== undefined ? options.defaultButton + 1 : 1; // AppleScript is 1-indexed const timeoutSeconds = options.timeoutSeconds || 45; // Map icon type to AppleScript icon const iconMap: Record<string, string> = { 'warning': 'caution', 'info': 'note', 'error': 'stop', 'question': 'note' }; const icon = options.icon ? iconMap[options.icon] || 'note' : 'note'; // Escape strings for AppleScript const escapeAS = (str: string) => str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const buttonsStr = buttons.map(b => `"${escapeAS(b)}"`).join(', '); // Build AppleScript as single line (continuation characters don't work with -e flag) const script = `tell application "System Events" to return button returned of (display dialog "${escapeAS(options.message)}" with title "${escapeAS(options.title)}" buttons {${buttonsStr}} default button ${defaultButton} with icon ${icon} giving up after ${timeoutSeconds})`; try { const { stdout } = await execAsync(`osascript -e ${JSON.stringify(script)}`); const buttonClicked = stdout.trim(); return { button: buttonClicked, cancelled: buttonClicked === 'Cancel' || buttonClicked === '' }; } catch (error: any) { // User cancelled (Command+Period or clicked X) if (error.code === 1) { return { button: 'Cancel', cancelled: true }; } throw error; } } /** * Show Windows dialog using PowerShell * * Note: Windows MessageBox doesn't support timeout natively. * The 45-second timeout is handled by our Promise.race wrapper in showNativeDialog(). */ async function showWindowsDialog(options: DialogOptions): Promise<DialogResult> { const buttons = options.buttons || ['OK', 'Cancel']; // Map to MessageBox buttons let buttonType = 'OKCancel'; if (buttons.length === 1) { buttonType = 'OK'; } else if (buttons.includes('Yes') && buttons.includes('No')) { buttonType = 'YesNo'; } const iconMap: Record<string, string> = { 'warning': 'Warning', 'info': 'Information', 'error': 'Error', 'question': 'Question' }; const icon = options.icon ? iconMap[options.icon] || 'Information' : 'Information'; // Escape strings for PowerShell (use backticks for quotes) const escapePS = (str: string) => str.replace(/"/g, '`"').replace(/\n/g, '`n'); // PowerShell as single-line command (multi-line scripts can fail with -Command) const script = `Add-Type -AssemblyName System.Windows.Forms; $result = [System.Windows.Forms.MessageBox]::Show("${escapePS(options.message)}", "${escapePS(options.title)}", [System.Windows.Forms.MessageBoxButtons]::${buttonType}, [System.Windows.Forms.MessageBoxIcon]::${icon}); Write-Output $result`; try { const { stdout } = await execAsync(`powershell -Command ${JSON.stringify(script)}`); const result = stdout.trim(); // Map PowerShell result to button text const buttonMap: Record<string, string> = { 'OK': buttons[0] || 'OK', 'Cancel': buttons[buttons.length - 1] || 'Cancel', 'Yes': 'Yes', 'No': 'No' }; const button = buttonMap[result] || result; return { button, cancelled: result === 'Cancel' || result === 'No' }; } catch (error: any) { throw new Error(`PowerShell dialog failed: ${error.message}`); } } /** * Show Linux dialog using zenity (fallback to kdialog) * * Note: Neither zenity nor kdialog support timeout natively. * The 45-second timeout is handled by our Promise.race wrapper in showNativeDialog(). * User must manually close dialog if they don't respond within timeout period. */ async function showLinuxDialog(options: DialogOptions): Promise<DialogResult> { // Try zenity first try { return await showZenityDialog(options); } catch (error) { // Fallback to kdialog try { return await showKDialogDialog(options); } catch (error2) { throw new Error('Neither zenity nor kdialog is available. Please install one of them.'); } } } /** * Show dialog using zenity (GNOME/GTK environments) */ async function showZenityDialog(options: DialogOptions): Promise<DialogResult> { const buttons = options.buttons || ['OK', 'Cancel']; const iconMap: Record<string, string> = { 'warning': 'warning', 'info': 'info', 'error': 'error', 'question': 'question' }; const icon = options.icon ? `--icon-name=${iconMap[options.icon]}` : ''; // Zenity uses --extra-button for custom buttons const extraButtons = buttons.slice(0, -1).map(b => `--extra-button="${b}"`).join(' '); const cancelButton = buttons[buttons.length - 1]; const cmd = `zenity --question --title="${options.title}" --text="${options.message}" ${icon} ${extraButtons} --ok-label="${buttons[0]}" --cancel-label="${cancelButton}"`; try { const { stdout } = await execAsync(cmd); const result = stdout.trim() || buttons[0]; return { button: result, cancelled: false }; } catch (error: any) { // Exit code 1 means Cancel was clicked if (error.code === 1) { return { button: cancelButton, cancelled: true }; } throw error; } } /** * Show dialog using kdialog (KDE/Qt environments) */ async function showKDialogDialog(options: DialogOptions): Promise<DialogResult> { const buttons = options.buttons || ['OK', 'Cancel']; const iconMap: Record<string, string> = { 'warning': 'warning', 'info': 'information', 'error': 'error', 'question': 'question' }; const icon = options.icon ? `--icon ${iconMap[options.icon]}` : ''; // KDialog uses --yes-label and --no-label const cmd = `kdialog --yesno "${options.message}" --title "${options.title}" ${icon} --yes-label "${buttons[0]}" --no-label "${buttons[buttons.length - 1]}"`; try { await execAsync(cmd); return { button: buttons[0], cancelled: false }; } catch (error: any) { // Exit code 1 means No/Cancel was clicked if (error.code === 1) { return { button: buttons[buttons.length - 1], cancelled: true }; } throw error; } } /** * Show a confirmation dialog with Approve/Cancel buttons * * Convenience wrapper around showNativeDialog with retry support * * @returns Object with approved flag and retry information */ export async function showConfirmDialog( title: string, message: string, approveText: string = 'Approve', cancelText: string = 'Cancel' ): Promise<{ approved: boolean; timedOut?: boolean; stillPending?: boolean; }> { const result = await showNativeDialog({ title, message, buttons: [approveText, cancelText], defaultButton: 0, icon: 'warning', timeoutSeconds: 45 // Short timeout for AI responsiveness }); return { approved: !result.cancelled && result.button === approveText, timedOut: result.timedOut, stillPending: result.stillPending }; }

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/portel-dev/ncp'

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