Skip to main content
Glama
senseisven

MCP Remote macOS Control Server

by senseisven
localMacOSClient.ts10.3 kB
import { spawn, exec } from 'child_process' import { promisify } from 'util' import { MCPClientInterface, MCPTool, MCPToolResult } from '../types/mcp' import logger from '../utils/logger' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' const execAsync = promisify(exec) export class LocalMacOSClient implements MCPClientInterface { private connected: boolean = false private tools: MCPTool[] = [] async connect(): Promise<void> { try { logger.info('Initializing local macOS client...') // Check if we're on macOS if (process.platform !== 'darwin') { throw new Error('LocalMacOSClient only works on macOS') } // Test if we can run basic commands await execAsync('which screencapture') await this.loadTools() this.connected = true logger.info('Local macOS client connected successfully') } catch (error) { logger.error('Failed to connect to local macOS:', error) throw error } } private async loadTools(): Promise<void> { this.tools = [ { name: 'remote_macos_get_screen', description: 'Take a screenshot of the local macOS desktop', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'remote_macos_mouse_click', description: 'Click at specified coordinates on the local macOS screen', inputSchema: { type: 'object', properties: { x: { type: 'integer', description: 'X coordinate to click' }, y: { type: 'integer', description: 'Y coordinate to click' }, source_width: { type: 'integer', description: 'Source screen width for coordinate scaling', default: 1366 }, source_height: { type: 'integer', description: 'Source screen height for coordinate scaling', default: 768 }, button: { type: 'integer', description: 'Mouse button: 1=left, 2=middle, 3=right', default: 1 } }, required: ['x', 'y'] } }, { name: 'remote_macos_send_keys', description: 'Send keyboard input to the local macOS system', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to type' }, special_key: { type: 'string', description: 'Special key to press', enum: ['enter', 'return', 'backspace', 'delete', 'tab', 'space', 'escape', 'up', 'down', 'left', 'right'] }, key_combo: { type: 'string', description: 'Key combination like "cmd+c", "cmd+v", etc.' } } } }, { name: 'remote_macos_open_application', description: 'Open an application on the local macOS system', inputSchema: { type: 'object', properties: { identifier: { type: 'string', description: 'Application name or path' } }, required: ['identifier'] } } ] } async listTools(): Promise<MCPTool[]> { return this.tools } async callTool(name: string, args: Record<string, any>): Promise<MCPToolResult> { if (!this.connected) { throw new Error('Local macOS client not connected') } try { switch (name) { case 'remote_macos_get_screen': return await this.takeScreenshot() case 'remote_macos_mouse_click': return await this.mouseClick(args) case 'remote_macos_send_keys': return await this.sendKeys(args) case 'remote_macos_open_application': return await this.openApplication(args) default: throw new Error(`Unknown tool: ${name}`) } } catch (error) { logger.error(`Error calling tool ${name}:`, error) return { success: false, error: `Error executing ${name}: ${error instanceof Error ? error.message : 'Unknown error'}` } } } private async takeScreenshot(): Promise<MCPToolResult> { try { const tempDir = os.tmpdir() const filename = `screenshot_${Date.now()}.png` const filepath = path.join(tempDir, filename) // Use macOS screencapture command await execAsync(`screencapture -x "${filepath}"`) // Read the file and convert to base64 const imageData = fs.readFileSync(filepath) const base64Data = imageData.toString('base64') // Clean up temp file fs.unlinkSync(filepath) // Get screen dimensions const { stdout } = await execAsync('system_profiler SPDisplaysDataType | grep Resolution') const resolutionMatch = stdout.match(/(\d+) x (\d+)/) const width = resolutionMatch ? parseInt(resolutionMatch[1]) : 1920 const height = resolutionMatch ? parseInt(resolutionMatch[2]) : 1080 return { success: true, imageData: base64Data, content: `Screenshot taken successfully. Screen resolution: ${width}x${height}` } } catch (error) { throw new Error(`Failed to take screenshot: ${error instanceof Error ? error.message : 'Unknown error'}`) } } private async mouseClick(args: Record<string, any>): Promise<MCPToolResult> { const { x, y, source_width = 1366, source_height = 768 } = args if (x === undefined || y === undefined) { throw new Error('x and y coordinates are required') } try { // Get actual screen dimensions const { stdout } = await execAsync('system_profiler SPDisplaysDataType | grep Resolution') const resolutionMatch = stdout.match(/(\d+) x (\d+)/) const actualWidth = resolutionMatch ? parseInt(resolutionMatch[1]) : 1920 const actualHeight = resolutionMatch ? parseInt(resolutionMatch[2]) : 1080 // Scale coordinates const scaledX = Math.round((x / source_width) * actualWidth) const scaledY = Math.round((y / source_height) * actualHeight) // Use cliclick for mouse control (install with: brew install cliclick) await execAsync(`cliclick c:${scaledX},${scaledY}`) return { success: true, content: `Mouse clicked at (${scaledX}, ${scaledY}) - scaled from source (${x}, ${y})` } } catch (error) { // Fallback to AppleScript try { const scaledX = Math.round((x / source_width) * 1920) // Fallback dimensions const scaledY = Math.round((y / source_height) * 1080) await execAsync(`osascript -e 'tell application "System Events" to click at {${scaledX}, ${scaledY}}'`) return { success: true, content: `Mouse clicked at (${scaledX}, ${scaledY}) using AppleScript fallback` } } catch (fallbackError) { throw new Error(`Failed to click mouse: ${error instanceof Error ? error.message : 'Unknown error'}. Try installing cliclick with: brew install cliclick`) } } } private async sendKeys(args: Record<string, any>): Promise<MCPToolResult> { const { text, special_key, key_combo } = args try { if (text) { // Type text using AppleScript const escapedText = text.replace(/"/g, '\\"').replace(/\\/g, '\\\\') await execAsync(`osascript -e 'tell application "System Events" to keystroke "${escapedText}"'`) return { success: true, content: `Typed text: "${text}"` } } else if (special_key) { // Handle special keys const keyMap: Record<string, string> = { 'enter': 'return', 'return': 'return', 'backspace': 'delete', 'delete': 'forward delete', 'tab': 'tab', 'space': 'space', 'escape': 'escape', 'up': 'up arrow', 'down': 'down arrow', 'left': 'left arrow', 'right': 'right arrow' } const macKey = keyMap[special_key] || special_key await execAsync(`osascript -e 'tell application "System Events" to key code (key code of "${macKey}")'`) return { success: true, content: `Pressed special key: ${special_key}` } } else if (key_combo) { // Handle key combinations like "cmd+c" const combo = key_combo.toLowerCase().replace(/\+/g, ' ') await execAsync(`osascript -e 'tell application "System Events" to keystroke "${combo}"'`) return { success: true, content: `Pressed key combination: ${key_combo}` } } else { throw new Error('Either text, special_key, or key_combo must be provided') } } catch (error) { throw new Error(`Failed to send keys: ${error instanceof Error ? error.message : 'Unknown error'}`) } } private async openApplication(args: Record<string, any>): Promise<MCPToolResult> { const { identifier } = args if (!identifier) { throw new Error('Application identifier is required') } try { // Try to open the application await execAsync(`open -a "${identifier}"`) return { success: true, content: `Successfully opened application: ${identifier}` } } catch (error) { throw new Error(`Failed to open application "${identifier}": ${error instanceof Error ? error.message : 'Unknown error'}`) } } isConnected(): boolean { return this.connected } disconnect(): void { this.connected = false logger.info('Local macOS client disconnected') } async getScreenDimensions(): Promise<{ width: number, height: number } | null> { try { const { stdout } = await execAsync('system_profiler SPDisplaysDataType | grep Resolution') const resolutionMatch = stdout.match(/(\d+) x (\d+)/) if (resolutionMatch) { return { width: parseInt(resolutionMatch[1]), height: parseInt(resolutionMatch[2]) } } return { width: 1920, height: 1080 } // Fallback } catch (error) { logger.warn('Failed to get screen dimensions:', error) return null } } }

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/senseisven/mcp_macos'

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