UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

369 lines (368 loc) • 18 kB
import { spawn } from 'child_process'; import path from 'path'; import fs from 'fs/promises'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js'; import { fileURLToPath } from 'url'; import { captureRemote } from '../utils/capture.js'; // Restart pacing: grows with consecutive failures, caps, and jitters so a // fleet-wide fault does not stampede. // // The cap is what a user waits after fixing whatever broke the child, so it is // deliberately short. It can be: readiness now keeps an unusable device out of // the server's selection, so no routed calls arrive to spawn anything, and the // only thing asking for a restart is this connector's own recovery loop. The // cost of the short cap is one short-lived spawn every few seconds while a // child stays broken; the cost of a long one is a device that sits dead for // most a minute after it could have come back. const RESTART_BACKOFF_CAP_MS = 5000; const restartBackoffMs = (attempt) => // Cap AFTER jitter, so the cap is the wait this comment claims it is. Math.min(RESTART_BACKOFF_CAP_MS, 250 * 2 ** Math.min(attempt, 5) * (0.5 + Math.random())); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export class DesktopCommanderIntegration { constructor() { this.mcpClient = null; this.mcpTransport = null; this.isReady = false; this.isShuttingDown = false; this.disconnectHandler = null; this.reinitPromise = null; /** Consecutive failed restarts; reset by a successful one. */ this.restartAttempts = 0; /** Before this, ensureReady() refuses rather than spawning again. */ this.nextRestartAt = 0; } /** True only while the local stdio child is actually reachable. */ get ready() { return this.isReady && this.mcpClient !== null; } /** * Register a callback fired when the local MCP child dies unexpectedly. * The device uses this to stop advertising itself as online — the remote * channel staying healthy says nothing about the local half being alive. */ onDisconnect(handler) { this.disconnectHandler = handler; } /** * The local child exited or its pipe broke. Previously nothing observed this: * `isReady` was a one-shot latch set in initialize() and cleared only by * shutdown(), so every later callClientTool() sailed past the readiness guard * and died inside the SDK with a bare "Not connected", forever, while the * device still reported itself online. */ handleLocalDisconnect(reason) { if (this.isShuttingDown) return; // expected teardown, not a fault if (!this.isReady) return; // already handled; don't double-fire this.isReady = false; this.mcpClient = null; this.mcpTransport = null; console.error(` - ❌ Local Desktop Commander MCP went away (${reason}); will restart on next tool call`); void captureRemote('desktop_integration_local_disconnected', { reason }); this.disconnectHandler?.(reason); } async initialize() { console.debug('[DEBUG] DesktopCommanderIntegration.initialize() called'); const config = await this.resolveMcpConfig(); if (!config) { console.debug('[DEBUG] No MCP config found'); throw new Error('Desktop Commander MCP not found. Please install it globally via `npm install -g @wonderwhy-er/desktop-commander` or build the local project.'); } console.log(` - ⏳ Connecting to Local Desktop Commander MCP using: ${config.command} ${config.args.join(' ')}`); console.debug('[DEBUG] MCP config:', JSON.stringify(config, null, 2)); try { console.debug('[DEBUG] Creating StdioClientTransport'); // DC_REMOTE_DEVICE tells the spawned server it is serving remote // services, so it suppresses local-only behavior like opening the // welcome page in a browser the remote user would never see. this.mcpTransport = new StdioClientTransport({ ...config, env: { ...getDefaultEnvironment(), ...config.env, DC_REMOTE_DEVICE: 'true' } }); // Create MCP client console.debug('[DEBUG] Creating MCP Client'); this.mcpClient = new Client({ name: "desktop-commander-client", version: "1.0.0" }, { capabilities: {} }); // Connect to Desktop Commander console.debug('[DEBUG] Connecting MCP client to transport'); // shutdown() sets the flag and tears down what exists; it cannot // reach back into a restart already in flight. Without these // checks that restart carries on past the teardown, connects a // child nobody will close and sets `ready` behind it. The catch // below discards whatever this attempt built. this.abortIfShuttingDown(); await this.mcpClient.connect(this.mcpTransport); this.abortIfShuttingDown(); // Supervise the local half. Without these, a child crash is silent: // the SDK clears its transport and every subsequent call throws // "Not connected" with nothing tying it back to the death. // // These MUST hang off the client, not the transport. connect() wraps // transport.onclose/onerror with its own handlers and the SDK states // that "The Protocol object assumes ownership of the Transport, // replacing any callbacks that have already been set". Assigning to // the transport here instead would drop the SDK's wrapper, and with // it Protocol._onclose() — the only place a pending response is // rejected with ConnectionClosed. The call in flight when the child // died would then hang for the SDK's 60s default request timeout // before the user heard anything. this.mcpClient.onclose = () => this.handleLocalDisconnect('stdio transport closed'); // Diagnostics only. Protocol.onerror is raised for eleven non-fatal // conditions that say nothing about the child's health — a response // for an unknown message id, an unknown progress token, a failed // cancellation send, an uncaught notification-handler error — and // treating any of those as death takes a working device offline and // respawns a live child. Real death arrives through onclose, which // only fires once the transport has actually closed. this.mcpClient.onerror = (err) => console.error(` - ⚠️ Local Desktop Commander MCP error: ${err?.message ?? String(err)}`); // Ready means the child has served a request, not that it // completed the handshake — one definition, whichever path // started the child, so no caller has to remember a second // step to get the stronger meaning. A failure here lands in // the catch below and leaves nothing half-built behind. await this.verifyExecution(); this.abortIfShuttingDown(); this.isReady = true; console.log(' - 🔌 Connected to Desktop Commander MCP'); console.debug('[DEBUG] Desktop Commander MCP connection successful'); } catch (error) { console.error(' - ❌ Failed to start Desktop Commander MCP:', error); console.debug('[DEBUG] MCP startup error:', error); // Leave no half-built child behind, or ensureReady() would treat // the corpse as live on the next attempt. Covers a child that // connected and then failed verification, too. await this.discardChild(); await captureRemote('desktop_integration_init_failed', { error }); throw error; } } /** * Guarantee a live local child before proxying a call, restarting it if the * previous one died. Restart is lazy (on demand) rather than a retry loop: * if the child is crashing on startup, each tool call fails with the real * reason instead of spinning respawns in the background. */ /** Same refusal ensureReady() makes up front, for an attempt already running. */ abortIfShuttingDown() { if (this.isShuttingDown) { throw new Error('Desktop Commander integration is shutting down'); } } async ensureReady() { if (this.ready) return; if (this.isShuttingDown) { throw new Error('Desktop Commander integration is shutting down'); } if (!this.reinitPromise) { // A child that crashes on start would otherwise be respawned once // per routed tool call. Refuse inside the window instead, so the // caller gets the real reason and the machine is left alone. const waitMs = this.nextRestartAt - Date.now(); if (waitMs > 0) { throw new Error(`Local Desktop Commander MCP failed to start ${this.restartAttempts} time(s); ` + `next attempt in ${Math.ceil(waitMs / 1000)}s`); } console.log(' - ♻️ Local Desktop Commander MCP is not running; restarting it...'); this.reinitPromise = this.restartChild().finally(() => { this.reinitPromise = null; }); } // Concurrent calls share the single in-flight restart. await this.reinitPromise; } /** One restart, with the pacing bookkeeping around it. */ async restartChild() { try { // initialize() resolves only once the child has served a request, // so there is nothing further to prove here. await this.initialize(); this.restartAttempts = 0; this.nextRestartAt = 0; } catch (error) { await this.discardChild(); this.restartAttempts++; this.nextRestartAt = Date.now() + restartBackoffMs(this.restartAttempts); throw error; } } /** Drop an unusable child so `ready` is false and the next attempt rebuilds. */ async discardChild() { this.isReady = false; const client = this.mcpClient; const transport = this.mcpTransport; this.mcpClient = null; this.mcpTransport = null; try { await client?.close(); } catch { /* already dead */ } try { await transport?.close(); } catch { /* already dead */ } } /** How long ensureReady() will refuse for. 0 when it will try immediately. */ get msUntilRestartAllowed() { return Math.max(0, this.nextRestartAt - Date.now()); } async resolveMcpConfig() { console.debug('[DEBUG] Resolving MCP config...'); // Option 1: Development/Local Build // Adjusting path resolution since we are now in src/remote-device and dist is in root/dist // Original: path.resolve(__dirname, '../../dist/index.js') const devPath = path.resolve(__dirname, '../../dist/index.js'); console.debug('[DEBUG] Checking local dev path:', devPath); try { await fs.access(devPath); console.debug(' - 🔍 Found local MCP server at:', devPath); return { command: process.execPath, // Use the current node executable args: [devPath], cwd: path.dirname(devPath) }; } catch { console.debug('[DEBUG] Local dev path not found, trying global installation'); // Local file not found, continue... } // Option 2: Global Installation const commandName = 'desktop-commander'; console.debug('[DEBUG] Checking for global command:', commandName); try { await new Promise((resolve, reject) => { // Use platform-appropriate command to check if the command exists in PATH // We can't run it directly as it's an stdio MCP server that waits for input const whichCommand = process.platform === 'win32' ? 'where' : 'which'; console.debug('[DEBUG] Using platform command:', whichCommand, 'on platform:', process.platform); const check = spawn(whichCommand, [commandName], { windowsHide: true }); // Prevent visible console windows on Windows check.on('error', (err) => { console.debug('[DEBUG] Spawn error for', whichCommand, ':', err.message); reject(err); }); check.on('close', (code) => { console.debug('[DEBUG]', whichCommand, 'exited with code:', code); return code === 0 ? resolve() : reject(new Error('Command not found')); }); }); console.debug(' - Found global desktop-commander CLI'); return { command: commandName, args: [] }; } catch (err) { console.debug('[DEBUG] Global command not found:', err); // Global command not found } console.debug('[DEBUG] No MCP config resolved'); return null; } async callClientTool(toolName, args, metadata) { // Restart the child if it died since the last call, so a one-off crash // costs one failed call instead of wedging the device until a human // restarts `desktop-commander remote`. await this.ensureReady(); // Proxy other tools to MCP server try { console.debug('[DEBUG] Calling MCP tool:', toolName, 'args:', JSON.stringify(args).substring(0, 100)); const result = await this.mcpClient.callTool({ name: toolName, arguments: args, _meta: { remote: true, ...metadata || {} } }); console.debug('[DEBUG] Tool call successful:', toolName); return result; } catch (error) { console.error(`Error executing tool ${toolName}:`, error); console.debug('[DEBUG] Tool call error details:', error); await captureRemote('desktop_integration_tool_call_failed', { error, toolName }); throw error; } } /** * Prove the child can serve a request, not merely that it completed the * handshake. connect() only exchanges `initialize`, which says the process * is up and speaks MCP - the same substitution issue #4 is about, one level * down. Throws so initialize() can withhold readiness; listClientTools() * keeps swallowing, because registerDevice() wants a tool list or nothing. */ async verifyExecution() { if (!this.mcpClient) throw new Error('Local Desktop Commander MCP is not connected'); await this.mcpClient.listTools(); } async listClientTools() { if (!this.mcpClient) return { tools: [] }; try { // List tools from MCP server const mcpTools = await this.mcpClient.listTools(); // Merge tools return { tools: mcpTools.tools || [] }; } catch (error) { console.error('Error fetching capabilities:', error); await captureRemote('desktop_integration_list_tools_failed', { error }); // Fallback to local tools return { tools: [] }; } } async shutdown() { console.debug('[DEBUG] DesktopCommanderIntegration.shutdown() called'); // Closing the transport fires onclose; flag this as intentional so it is // not reported as a crash. this.isShuttingDown = true; const closeWithTimeout = async (operation, name, timeoutMs = 3000) => { return Promise.race([ operation(), new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} timeout after ${timeoutMs}ms`)), timeoutMs)) ]); }; if (this.mcpClient) { try { console.log(' → Closing MCP client...'); console.debug('[DEBUG] Calling mcpClient.close() with timeout'); await closeWithTimeout(() => this.mcpClient.close(), 'MCP client close'); console.log(' ✓ MCP client closed'); } catch (e) { console.warn(' ⚠️ MCP client close timeout or error:', e.message); console.debug('[DEBUG] MCP client close error:', e); await captureRemote('desktop_integration_shutdown_error', { error: e, component: 'client' }); } this.mcpClient = null; } if (this.mcpTransport) { try { console.log(' → Closing MCP transport...'); console.debug('[DEBUG] Calling mcpTransport.close() with timeout'); await closeWithTimeout(() => this.mcpTransport.close(), 'MCP transport close'); console.log(' ✓ MCP transport closed'); } catch (e) { console.warn(' ⚠️ MCP transport close timeout or error:', e.message); console.debug('[DEBUG] MCP transport close error:', e); await captureRemote('desktop_integration_shutdown_error', { error: e, component: 'transport' }); } this.mcpTransport = null; } this.isReady = false; console.debug('[DEBUG] Desktop Commander integration shutdown complete'); } }