UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

325 lines (324 loc) • 15.4 kB
#!/usr/bin/env node import { RemoteChannel } from './remote-channel.js'; import { DeviceAuthenticator } from './device-authenticator.js'; import { DesktopCommanderIntegration } from './desktop-commander-integration.js'; import { fileURLToPath } from 'url'; import os from 'os'; import fs from 'fs/promises'; import path from 'path'; import { captureRemote } from '../utils/capture.js'; export class MCPDevice { constructor(options = {}) { this.baseServerUrl = process.env.MCP_SERVER_URL || 'https://mcp.desktopcommander.app'; this.remoteChannel = new RemoteChannel(); this.deviceId = undefined; this.isShuttingDown = false; this.configPath = path.join(os.homedir(), '.desktop-commander-device', 'device.json'); this.persistSession = options.persistSession || false; // Initialize desktop integration this.desktop = new DesktopCommanderIntegration(); // Graceful shutdown handlers (only set once) this.setupShutdownHandlers(); } setupShutdownHandlers() { const handleShutdown = async (signal) => { if (this.isShuttingDown) { console.log(`\n${signal} received, but already shutting down...`); // Force exit if we get multiple signals process.exit(1); return; } console.log(`\n${signal} received, initiating graceful shutdown...`); // Force exit after 5 seconds if graceful shutdown hangs const forceExit = setTimeout(() => { console.error('\nāš ļø Graceful shutdown timed out, forcing exit...'); process.exit(1); }, 5000); try { await this.shutdown(); clearTimeout(forceExit); process.exit(0); } catch (error) { console.error('Error during shutdown:', error); await captureRemote('remote_device_shutdown_handler_error', { error }); process.exit(1); } }; // Remove any existing SIGINT/SIGTERM listeners to prevent default behavior // process.removeAllListeners('SIGINT'); // process.removeAllListeners('SIGTERM'); // Add our custom handlers process.on('SIGINT', () => { handleShutdown('SIGINT').catch((error) => { console.error('Fatal error during shutdown:', error); captureRemote('remote_device_shutdown_handler_error', { error, signal: 'SIGINT' }).catch(() => { }); process.exit(1); }); }); process.on('SIGTERM', () => { handleShutdown('SIGTERM').catch((error) => { console.error('Fatal error during shutdown:', error); captureRemote('remote_device_shutdown_handler_error', { error, signal: 'SIGTERM' }).catch(() => { }); process.exit(1); }); }); } async start() { try { console.log('šŸš€ Starting MCP Device...'); if (process.env.DEBUG_MODE === 'true') { console.log(` - šŸž DEBUG_MODE`); } // Initialize desktop integration await this.desktop.initialize(); console.log(`ā³ Connecting to Remote MCP ${this.baseServerUrl}`); const { supabaseUrl, anonKey } = await this.fetchSupabaseConfig(); console.log(` - šŸ”Œ Connected to Remote MCP`); // Initialize Remote Channel this.remoteChannel.initialize(supabaseUrl, anonKey); // Load persisted configuration (deviceId, session) let session = await this.loadPersistedConfig(); // 2. Set Session or Authenticate if (session) { const { error } = await this.remoteChannel.setSession(session); if (error) { console.log(' - āš ļø Persisted session invalid:', error.message); session = null; } else { console.log(' - āœ… Session restored'); } } if (!session) { console.log('\nšŸ” Authenticating with Remote MCP server...'); const authenticator = new DeviceAuthenticator(this.baseServerUrl); session = await authenticator.authenticate(this.deviceId); if (session.device_id) { if (!this.deviceId) { await captureRemote('remote_device_auth_success', { "device": "assigned" }); console.log(` - āœ… Device ID assigned: ${session.device_id}`); } else if (this.deviceId !== session.device_id) { await captureRemote('remote_device_auth_success', { "device": "changed" }); console.log(` - āš ļø Device ID changed: ${this.deviceId} → ${session.device_id}`); } else { await captureRemote('remote_device_auth_success', { "device": "authenticated" }); console.log(` - āœ… Device ID authenticated: ${session.device_id}`); } this.deviceId = session.device_id; } // Set session in Remote Channel const { error } = await this.remoteChannel.setSession(session); if (error) throw error; } // Force save the current session immediately to ensure it's persisted await this.savePersistedConfig(); const deviceName = os.hostname(); // Register as device await this.remoteChannel.registerDevice(await this.desktop.listClientTools(), this.deviceId, deviceName, (payload) => this.handleNewToolCall(payload)); console.log('āœ… Device ready:'); console.log(` - User: ${this.remoteChannel.user.email}`); console.log(` - Device ID: ${this.deviceId}`); console.log(` - Device Name: ${deviceName}`); // Keep process alive this.remoteChannel.startHeartbeat(this.deviceId); } catch (error) { console.error(' - āŒ Device startup failed:', error.message); if (error.stack && process.env.DEBUG_MODE === 'true') { console.error('Stack trace:', error.stack); } await captureRemote('remote_device_startup_failed', { error }); await this.shutdown(); process.exit(1); } } async loadPersistedConfig() { try { console.debug('[DEBUG] Loading persisted config from:', this.configPath); const data = await fs.readFile(this.configPath, 'utf8'); const config = JSON.parse(data); this.deviceId = config?.deviceId; console.debug('[DEBUG] Loaded device ID:', this.deviceId); console.log('šŸ’¾ Found persisted session for device ' + this.deviceId); if (config.session) { console.debug('[DEBUG] Session found in config, returning session'); return config.session; } console.debug('[DEBUG] No session in config'); return null; } catch (error) { if (error.code !== 'ENOENT') { console.warn('āš ļø Failed to load config:', error.message); await captureRemote('remote_device_config_load_error', { error }); } else { console.debug('[DEBUG] Config file does not exist (ENOENT)'); } return null; } finally { // No need to ensure device ID here } } async savePersistedConfig() { try { console.debug('[DEBUG] Saving persisted config, persistSession:', this.persistSession); const currentSessionStore = await this.remoteChannel.getSession(); const session = currentSessionStore.data.session; const config = { deviceId: this.deviceId, // Only save session if --persist-session flag is set session: (session && this.persistSession) ? { access_token: session.access_token, refresh_token: session.refresh_token } : null }; // Ensure the config directory exists console.debug('[DEBUG] Creating config directory:', path.dirname(this.configPath)); await fs.mkdir(path.dirname(this.configPath), { recursive: true }); await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); console.debug('[DEBUG] Config saved to:', this.configPath); } catch (error) { console.error(' - āŒ Failed to save config:', error.message); console.debug('[DEBUG] Config save error details:', error); await captureRemote('remote_device_config_save_error', { error }); } } async fetchSupabaseConfig() { // No auth header needed for this public endpoint console.debug('[DEBUG] Fetching Supabase config from:', `${this.baseServerUrl}/api/mcp-info`); const response = await fetch(`${this.baseServerUrl}/api/mcp-info`); if (!response.ok) { console.debug('[DEBUG] Supabase config fetch failed, status:', response.status, response.statusText); throw new Error(`Failed to fetch Supabase config: ${response.statusText}`); } const config = await response.json(); console.debug('[DEBUG] Supabase config received, URL:', config.supabaseUrl?.substring(0, 30) + '...'); return { supabaseUrl: config.supabaseUrl, anonKey: config.supabasePublishableKey }; } // Methods moved to RemoteChannel async handleNewToolCall(payload) { const toolCall = payload.new; // Expect toolCall to include a device_id field used to route calls to this device instance. const { id: call_id, tool_name, tool_args, device_id, metadata = {} } = toolCall; console.debug('[DEBUG] Tool call received, device_id:', device_id, 'this.deviceId:', this.deviceId); // Only process jobs for this device if (device_id && device_id !== this.deviceId) { console.debug('[DEBUG] Ignoring tool call for different device'); return; } console.log(`šŸ”§ Received tool call ${call_id}: ${tool_name} ${JSON.stringify(tool_args)} metadata: ${JSON.stringify(metadata)}`); try { // Update call status to executing await this.remoteChannel.markCallExecuting(call_id); let result; // Handle 'ping' tool specially if (tool_name === 'ping') { result = { content: [{ type: 'text', text: `pong ${new Date().toISOString()}` }] }; } else if (tool_name === 'shutdown') { result = { content: [{ type: 'text', text: `Shutdown initialized at ${new Date().toISOString()}` }] }; // Trigger shutdown after sending response setTimeout(async () => { console.log('šŸ›‘ Remote shutdown requested. Exiting...'); await this.shutdown(); process.exit(0); }, 1000); } else { // Execute other tools using desktop integration result = await this.desktop.callClientTool(tool_name, tool_args, metadata); } console.log(`āœ… Tool call ${tool_name} completed:\r\n ${JSON.stringify(result)}`); // Update database with result await this.remoteChannel.updateCallResult(call_id, 'completed', result); } catch (error) { console.error(`āŒ Tool call ${tool_name} failed:`, error.message); await captureRemote('remote_device_tool_call_failed', { error, tool_name }); await this.remoteChannel.updateCallResult(call_id, 'failed', null, error.message); } } async shutdown() { if (this.isShuttingDown) { console.debug('[DEBUG] Shutdown already in progress, returning'); return; } this.isShuttingDown = true; console.log('\nšŸ›‘ Shutting down device...'); console.debug('[DEBUG] Shutdown initiated for device:', this.deviceId); try { // Stop heartbeat first to prevent new operations console.log(' → Stopping heartbeat...'); console.debug('[DEBUG] Calling stopHeartbeat()'); this.remoteChannel.stopHeartbeat(); console.log(' āœ“ Heartbeat stopped'); // Unsubscribe from channel console.log(' → Unsubscribing from channel...'); console.debug('[DEBUG] Calling channel.unsubscribe()'); await this.remoteChannel.unsubscribe(); // Mark device offline console.log(' → Marking device offline...'); console.debug('[DEBUG] Calling setOffline() with deviceId:', this.deviceId); await this.remoteChannel.setOffline(this.deviceId); // Shutdown desktop integration console.log(' → Shutting down desktop integration...'); console.debug('[DEBUG] Calling desktop.shutdown()'); await this.desktop.shutdown(); console.log(' āœ“ Desktop integration shut down'); console.log('āœ“ Device shutdown complete'); console.debug('[DEBUG] Shutdown sequence completed successfully'); } catch (error) { console.error('Shutdown error:', error.message); console.debug('[DEBUG] Shutdown error stack:', error.stack); await captureRemote('remote_device_shutdown_error', { error }); } } } // Start device if called directly or as a bin command // When installed globally, npm creates a wrapper, so we need to check multiple conditions const isMainModule = process.argv[1] && ( // Direct execution: node device.js import.meta.url === `file://${process.argv[1]}` || fileURLToPath(import.meta.url) === process.argv[1] || // Global bin execution: desktop-commander-device (npm creates a wrapper) process.argv[1].endsWith('desktop-commander-device') || process.argv[1].endsWith('desktop-commander-device.js')); if (isMainModule) { // Parse command-line arguments const args = process.argv.slice(2); const options = { persistSession: args.includes('--persist-session') }; if (options.persistSession) { console.log('šŸ”’ Session persistence enabled'); } const device = new MCPDevice(options); device.start(); }