UNPKG

@mcpcn/mcp-dialog

Version:

用户提示对话框MCP服务器

449 lines (448 loc) 17.9 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); /** * Escapes special characters in strings for AppleScript */ function escapeString(str) { // Escape for both AppleScript and shell return str .replace(/'/g, "'\\''") .replace(/"/g, '\\"'); } // 检测操作系统 function getOS() { const currentPlatform = process.platform; if (currentPlatform === 'win32') return 'windows'; if (currentPlatform === 'darwin') return 'macos'; return 'linux'; } /** * Validates confirm parameters */ function validateConfirmParams(params) { if (!params.message || typeof params.message !== 'string') { throw new Error('Message is required and must be a string'); } if (params.confirmText && typeof params.confirmText !== 'string') { throw new Error('Confirm text must be a string'); } if (params.cancelText && typeof params.cancelText !== 'string') { throw new Error('Cancel text must be a string'); } if (params.icon && !['note', 'stop', 'caution'].includes(params.icon)) { throw new Error('Icon must be one of: note, stop, caution'); } } /** * Validates prompt parameters */ function validatePromptParams(params) { if (!params.message || typeof params.message !== 'string') { throw new Error('Message is required and must be a string'); } if (params.defaultAnswer && typeof params.defaultAnswer !== 'string') { throw new Error('Default answer must be a string'); } if (params.buttons) { if (!Array.isArray(params.buttons) || !params.buttons.every(b => typeof b === 'string')) { throw new Error('Buttons must be an array of strings'); } if (params.buttons.length > 3) { throw new Error('Maximum of 3 buttons allowed'); } } if (params.icon && !['note', 'stop', 'caution'].includes(params.icon)) { throw new Error('Icon must be one of: note, stop, caution'); } } /** * Builds the AppleScript command for displaying a prompt */ function buildMacOSPromptCommand(params) { let script = 'display dialog'; script += ` "${escapeString(params.message)}"`; // 始终显示输入框,如果没有 defaultAnswer 就使用空字符串 script += ` default answer "${escapeString(params.defaultAnswer || '')}"`; if (params.buttons && params.buttons.length > 0) { script += ` buttons {${params.buttons.map(b => `"${escapeString(b)}"`).join(', ')}}`; script += ` default button ${params.buttons.length}`; } else { script += ' buttons {"Cancel", "OK"} default button 2'; } if (params.icon) { script += ` with icon ${params.icon}`; } return `osascript -e '${script}'`; } /** * Builds the Windows PowerShell command for displaying a prompt */ function buildWindowsPromptCommand(params) { const { message, defaultAnswer, buttons, icon } = params; // 映射图标 const iconMap = { 'note': 'Information', 'stop': 'Error', 'caution': 'Warning' }; const iconType = icon ? iconMap[icon] || 'Information' : 'Information'; const buttonList = buttons || ['Cancel', 'OK']; // 始终显示输入框(prompt_user 工具的目的就是获取用户输入) const script = ` Add-Type -AssemblyName Microsoft.VisualBasic; $result = [Microsoft.VisualBasic.Interaction]::InputBox('${escapeString(message)}', 'Input', '${escapeString(defaultAnswer || '')}'); if ($result -eq '') { Write-Output 'button returned:Cancel, text returned:' } else { Write-Output "button returned:OK, text returned:$result" } `; return `powershell -Command "${script.replace(/\n\s+/g, ' ')}"`; } // 新增:检查并安装 zenity async function ensureZenityInstalled() { try { // 检查 zenity 是否已安装 await execAsync('which zenity || zenity --version').catch(() => { }); } catch { try { // 根据发行版选择包管理器 const { stdout: distro } = await execAsync('grep ^ID= /etc/os-release | cut -d= -f2'); const pkgManager = distro.trim() === 'debian' || distro.trim() === 'ubuntu' ? 'apt' : 'dnf'; await execAsync(`sudo ${pkgManager} install -y zenity`); console.error('zenity 安装成功!'); } catch (installError) { throw new Error(`自动安装 zenity 失败,请手动运行以下命令安装:\n` + ` Ubuntu/Debian: sudo apt install zenity\n` + ` Fedora/RHEL: sudo dnf install zenity`); } } } /** * Builds the Linux command for displaying a prompt (using zenity) */ async function buildLinuxPromptCommand(params) { await ensureZenityInstalled(); // 确保 zenity 存在 const { message, defaultAnswer, buttons, icon } = params; const iconMap = { 'note': '--info', 'stop': '--error', 'caution': '--warning' }; const iconFlag = icon ? iconMap[icon] || '--info' : '--info'; // 始终显示输入框(prompt_user 工具的目的就是获取用户输入) let command = `zenity --entry --text="${escapeString(message)}"`; command += ` --entry-text="${escapeString(defaultAnswer || '')}"`; command += ` --title="Input"`; return `bash -c ' result=$(${command} 2>/dev/null) if [ $? -eq 0 ]; then echo "button returned:OK, text returned:$result" else echo "button returned:Cancel, text returned:" fi '`; } /** * Prompts the user for input using platform-appropriate dialogs */ async function promptUser(params) { try { validatePromptParams(params); const os = getOS(); let command; switch (os) { case 'macos': command = buildMacOSPromptCommand(params); break; case 'windows': command = buildWindowsPromptCommand(params); break; case 'linux': command = await buildLinuxPromptCommand(params); break; default: throw new Error(`Unsupported platform: ${os}`); } const { stdout } = await execAsync(command); // Parse the result (all platforms now return in same format) // Format: button returned:OK, text returned:user input const match = stdout.trim().match(/button returned:([^,]+)(?:, text returned:(.*))?/); if (!match) { throw new Error('Failed to parse dialog result'); } const buttonText = match[1]; const text = match[2] || ''; return { text: text, selectedButton: buttonText }; } catch (error) { const err = error; console.error(err.message); if (err.message.includes('User canceled') || err.message.includes('was cancelled')) { throw new Error('User cancelled the prompt'); } else if (err.message.includes('execution error')) { throw new Error('Failed to execute prompt command'); } else if (err.message.includes('permission')) { throw new Error('Permission denied when trying to show prompt'); } else { throw new Error(`Unexpected error: ${err.message}`); } } } /** * Builds confirmation dialog commands for different platforms */ function buildConfirmCommand(params) { const { message, confirmText = '确认', cancelText = '取消', icon } = params; const os = getOS(); switch (os) { case 'macos': { let script = `display dialog "${escapeString(message)}"`; script += ` buttons {"${escapeString(cancelText)}", "${escapeString(confirmText)}"}`; script += ` default button 2`; if (icon) { script += ` with icon ${icon}`; } // 使用 try/catch 处理用户取消的情况 return `osascript -e 'try set result to ${script} set buttonPressed to button returned of result if buttonPressed is equal to "${escapeString(confirmText)}" then return "button returned:${escapeString(confirmText)}" else return "button returned:${escapeString(cancelText)}" end if on error return "button returned:${escapeString(cancelText)}" end try'`; } case 'windows': { const iconMap = { 'note': 'Information', 'stop': 'Error', 'caution': 'Warning' }; const iconType = icon ? iconMap[icon] || 'Information' : 'Information'; const script = ` Add-Type -AssemblyName System.Windows.Forms; $result = [System.Windows.Forms.MessageBox]::Show('${escapeString(message)}', 'Confirm', 'OKCancel', '${iconType}'); if ($result -eq 'OK') { Write-Output 'button returned:${escapeString(confirmText)}' } else { Write-Output 'button returned:${escapeString(cancelText)}' } `; return `powershell -Command "${script.replace(/\n\s+/g, ' ')}"`; } case 'linux': { const iconFlag = icon ? `--${icon === 'note' ? 'info' : icon === 'stop' ? 'error' : 'warning'}` : '--question'; return `bash -c ' if zenity --question ${iconFlag} --text="${escapeString(message)}" --ok-label="${escapeString(confirmText)}" --cancel-label="${escapeString(cancelText)}" --title="Confirm" 2>/dev/null; then echo "button returned:${escapeString(confirmText)}" else echo "button returned:${escapeString(cancelText)}" fi '`; } default: throw new Error(`Unsupported platform: ${os}`); } } /** * Shows a confirmation dialog to the user */ async function confirmUser(params) { try { validateConfirmParams(params); const confirmText = params.confirmText || '确认'; const cancelText = params.cancelText || '取消'; const command = buildConfirmCommand(params); const { stdout } = await execAsync(command); // Parse the result const match = stdout.trim().match(/button returned:(.+)/); if (!match) { throw new Error('Failed to parse dialog result'); } const selectedButton = match[1]; const confirmed = selectedButton === confirmText; return { confirmed, selectedButton }; } catch (error) { const err = error; console.error(err.message); // 如果是用户取消操作,返回取消结果而不是抛出错误 if (err.message.includes('用户已取消') || err.message.includes('User canceled') || err.message.includes('cancelled')) { return { confirmed: false, selectedButton: params.cancelText || '取消' }; } // 其他错误继续抛出 throw new Error(`确认对话框执行失败: ${err.message}`); } } class PromptServer { constructor() { this.server = new Server({ name: 'prompt-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, }); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } setupToolHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'prompt_user', description: '显示prompt dialog,需要获得用户输入时,可以调用此工具', inputSchema: { type: 'object', properties: { message: { type: 'string', description: '在提示对话框中显示的文本', }, defaultAnswer: { type: 'string', description: '可选的默认预填文本', }, buttons: { type: 'array', items: { type: 'string' }, description: '可选的自定义按钮标签(最多3个)', maxItems: 3 }, icon: { type: 'string', enum: ['note', 'stop', 'caution'], description: '可选的显示图标' } }, required: ['message'], additionalProperties: false, }, }, { name: 'confirm_user', description: '显示confirm dialog,用于需要用户确认操作时调用,比如是否删除文件,是否覆盖保存文件,是否退出程序等', inputSchema: { type: 'object', properties: { message: { type: 'string', description: '在确认对话框中显示的文本', }, confirmText: { type: 'string', description: '确认按钮的文本,默认为"确认"', default: '确认' }, cancelText: { type: 'string', description: '取消按钮的文本,默认为"取消"', default: '取消' }, icon: { type: 'string', enum: ['note', 'stop', 'caution'], description: '可选的显示图标' } }, required: ['message'], additionalProperties: false, }, }, ], })); // Handle tool execution this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!request.params.arguments || typeof request.params.arguments !== 'object') { throw new McpError(ErrorCode.InvalidParams, 'Invalid parameters'); } switch (request.params.name) { case 'prompt_user': { const { message, defaultAnswer, buttons, icon } = request.params.arguments; const params = { message: message, defaultAnswer: typeof defaultAnswer === 'string' ? defaultAnswer : undefined, buttons: Array.isArray(buttons) ? buttons : undefined, icon: ['note', 'stop', 'caution'].includes(icon) ? icon : undefined }; const result = await promptUser(params); return { content: [ { type: 'text', text: JSON.stringify(result), }, ], }; } case 'confirm_user': { const { message, confirmText, cancelText, icon } = request.params.arguments; const params = { message: message, confirmText: typeof confirmText === 'string' ? confirmText : '确认', cancelText: typeof cancelText === 'string' ? cancelText : '取消', icon: ['note', 'stop', 'caution'].includes(icon) ? icon : undefined }; const result = await confirmUser(params); return { content: [ { type: 'text', text: JSON.stringify(result), }, ], }; } default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`); } } catch (error) { throw error; } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Prompt MCP server running on stdio'); } } const server = new PromptServer(); server.run().catch(console.error);