# Directory Structure ``` ├── .gitignore ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── network-discovery.ts │ └── server.ts ├── tsconfig.json └── vite.config.ts ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | dist 2 | .claude 3 | node_modules 4 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Eufy RoboVac MCP Server 2 | 3 | A Model Context Protocol (MCP) server for controlling Eufy RoboVac devices. Built with TypeScript and Vite. 4 | 5 | ## Setup 6 | 7 | 1. Install dependencies: 8 | ```bash 9 | npm install 10 | ``` 11 | 12 | 2. Get your device credentials: 13 | - Device ID and Local Key from your Eufy Home app or network analysis 14 | - Find your RoboVac's IP address on your network 15 | 16 | ## Development 17 | 18 | Run in development mode with hot reload: 19 | ```bash 20 | npm run dev 21 | ``` 22 | 23 | Type checking: 24 | ```bash 25 | npm run typecheck 26 | ``` 27 | 28 | ## Production 29 | 30 | Build the project: 31 | ```bash 32 | npm run build 33 | ``` 34 | 35 | Run the built server: 36 | ```bash 37 | npm start 38 | ``` 39 | 40 | ## Available Tools 41 | 42 | ### Connection & Setup 43 | - `robovac_scan_network` - Scan local network for RoboVac devices (🆕 no credentials needed!) 44 | - `robovac_connect_discovered` - Connect to a discovered device using its IP 45 | - `robovac_connect` - Manual connection using device credentials 46 | - `robovac_auto_initialize` - Cloud-based discovery (⚠️ May not work due to API changes) 47 | 48 | ### Basic Controls 49 | - `robovac_start_cleaning` - Start cleaning cycle 50 | - `robovac_stop_cleaning` - Stop cleaning cycle 51 | - `robovac_return_home` - Return to charging dock 52 | - `robovac_play` - Start/resume cleaning 53 | - `robovac_pause` - Pause cleaning 54 | - `robovac_find_robot` - Make the RoboVac beep to locate it 55 | 56 | ### Advanced Controls 57 | - `robovac_set_work_mode` - Set cleaning mode (AUTO, SMALL_ROOM, SPOT, EDGE, NO_SWEEP) 58 | - `robovac_set_clean_speed` - Set suction power (STANDARD, BOOST_IQ, MAX, NO_SUCTION) 59 | 60 | ### Status Information 61 | - `robovac_get_status` - Get current device status (legacy) 62 | - `robovac_get_battery` - Get battery level 63 | - `robovac_get_error_code` - Get current error code 64 | - `robovac_get_work_mode` - Get current cleaning mode 65 | - `robovac_get_clean_speed` - Get current suction level 66 | - `robovac_get_work_status` - Get detailed work status 67 | - `robovac_get_play_pause` - Get play/pause state 68 | 69 | ### Utility Functions 70 | - `robovac_format_status` - Print formatted status to console 71 | - `robovac_get_all_statuses` - Get all status information at once 72 | 73 | ## Usage with MCP Client 74 | 75 | ### 🆕 Easy Setup with Network Scan (Recommended) 76 | 77 | 1. **Scan your local network to find RoboVac devices:** 78 | ``` 79 | robovac_scan_network() 80 | ``` 81 | This will show you: 82 | - All devices with open Tuya/Eufy ports (6668, 6667, 443) 83 | - Devices with Anker/Eufy MAC addresses (⭐ likely RoboVacs) 84 | - IP addresses of potential devices 85 | 86 | 2. **Connect to a discovered device:** 87 | ``` 88 | robovac_connect_discovered(ip="192.168.1.100", deviceId="your_device_id", localKey="your_local_key") 89 | ``` 90 | 91 | ### Getting Device Credentials 92 | You still need the device ID and local key: 93 | 94 | 1. **Try community tools:** 95 | - `eufy-security-client` or similar projects 96 | - Check GitHub for updated credential grabbers 97 | 98 | 2. **Network traffic analysis:** 99 | - Monitor Eufy app network traffic 100 | - Use tools like Wireshark or Charles Proxy 101 | 102 | 3. **Router/firmware methods:** 103 | - Some routers log device information 104 | - Check if your RoboVac firmware exposes credentials 105 | 106 | ### Alternative Methods 107 | 108 | **Manual connection (if you have all credentials):** 109 | ``` 110 | robovac_connect(deviceId="your_device_id", localKey="your_local_key", ip="192.168.1.100") 111 | ``` 112 | 113 | **Cloud discovery (may not work due to API changes):** 114 | ``` 115 | robovac_auto_initialize(email="[email protected]", password="your_password") 116 | ``` 117 | 118 | ### Control Your RoboVac 119 | Once connected, use any control tools: 120 | ``` 121 | robovac_start_cleaning() 122 | robovac_get_status() 123 | robovac_return_home() 124 | robovac_set_work_mode(mode="SPOT") 125 | robovac_set_clean_speed(speed="MAX") 126 | ``` ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "declaration": true, 16 | "declarationMap": true, 17 | "sourceMap": true, 18 | "outDir": "./dist", 19 | "rootDir": "./src", 20 | "types": ["node"] 21 | }, 22 | "include": [ 23 | "src/**/*" 24 | ], 25 | "exclude": [ 26 | "node_modules", 27 | "dist" 28 | ] 29 | } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "sam", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/server.es.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "tsx src/server.ts", 9 | "build": "vite build", 10 | "start": "node dist/server.es.js", 11 | "typecheck": "tsc --noEmit", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "@modelcontextprotocol/sdk": "^1.16.0", 19 | "crypto-js": "^4.2.0", 20 | "eufy-robovac": "^1.4.6", 21 | "node-rsa": "^1.1.1" 22 | }, 23 | "devDependencies": { 24 | "@types/crypto-js": "^4.2.2", 25 | "@types/node": "^24.1.0", 26 | "@types/node-rsa": "^1.1.4", 27 | "tsx": "^4.20.3", 28 | "typescript": "^5.8.3", 29 | "vite": "^7.0.5" 30 | } 31 | } 32 | ``` -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { defineConfig } from "vite"; 2 | import { resolve } from "path"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | entry: resolve(__dirname, "src/server.ts"), 8 | name: "EufyRoboVacMCPServer", 9 | fileName: (format) => `server.${format}.js`, 10 | formats: ["es"], 11 | }, 12 | rollupOptions: { 13 | external: [ 14 | "@modelcontextprotocol/sdk/server/index.js", 15 | "@modelcontextprotocol/sdk/server/stdio.js", 16 | "@modelcontextprotocol/sdk/types.js", 17 | "eufy-robovac", 18 | "crypto", 19 | "crypto-js", 20 | "node-rsa", 21 | "net", 22 | "child_process", 23 | "os", 24 | ], 25 | }, 26 | target: "node18", 27 | outDir: "dist", 28 | }, 29 | resolve: { 30 | alias: { 31 | "@": resolve(__dirname, "src"), 32 | }, 33 | }, 34 | }); 35 | ``` -------------------------------------------------------------------------------- /src/network-discovery.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as net from "net"; 2 | import { execSync } from "child_process"; 3 | import * as os from "os"; 4 | 5 | interface NetworkDevice { 6 | ip: string; 7 | mac?: string; 8 | vendor?: string; 9 | ports: number[]; 10 | isLikelyRoboVac: boolean; 11 | } 12 | 13 | export class NetworkDiscovery { 14 | private readonly TUYA_PORTS = [6668, 6667, 443]; 15 | private readonly ANKER_EUFY_OUIS = [ 16 | "34:ea:34", // Anker Innovations Limited 17 | "70:55:82", // Anker Innovations Limited 18 | "90:9a:4a", // Anker Innovations Limited 19 | "a4:c1:38", // Anker Innovations Limited 20 | "2c:aa:8e", // Anker Innovations Limited 21 | ]; 22 | 23 | private getLocalNetworkRange(): string { 24 | const interfaces = os.networkInterfaces(); 25 | 26 | for (const [name, addrs] of Object.entries(interfaces)) { 27 | if (!addrs) continue; 28 | 29 | for (const addr of addrs) { 30 | if (addr.family === "IPv4" && !addr.internal) { 31 | const parts = addr.address.split("."); 32 | if (parts[0] === "192" && parts[1] === "168") { 33 | return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`; 34 | } else if (parts[0] === "10") { 35 | return "10.0.0.0/24"; 36 | } else if ( 37 | parts[0] === "172" && 38 | parseInt(parts[1]) >= 16 && 39 | parseInt(parts[1]) <= 31 40 | ) { 41 | return `172.${parts[1]}.0.0/16`; 42 | } 43 | } 44 | } 45 | } 46 | 47 | return "192.168.1.0/24"; // Default fallback 48 | } 49 | 50 | private async scanPort( 51 | ip: string, 52 | port: number, 53 | timeout: number = 1000 54 | ): Promise<boolean> { 55 | return new Promise((resolve) => { 56 | const socket = new net.Socket(); 57 | 58 | const timer = setTimeout(() => { 59 | socket.destroy(); 60 | resolve(false); 61 | }, timeout); 62 | 63 | socket.on("connect", () => { 64 | clearTimeout(timer); 65 | socket.destroy(); 66 | resolve(true); 67 | }); 68 | 69 | socket.on("error", () => { 70 | clearTimeout(timer); 71 | resolve(false); 72 | }); 73 | 74 | socket.connect(port, ip); 75 | }); 76 | } 77 | 78 | private async getArpTable(): Promise<Map<string, string>> { 79 | const arpMap = new Map<string, string>(); 80 | 81 | try { 82 | let arpOutput: string; 83 | const platform = os.platform(); 84 | 85 | if (platform === "darwin" || platform === "linux") { 86 | arpOutput = execSync("arp -a", { encoding: "utf8", timeout: 5000 }); 87 | } else if (platform === "win32") { 88 | arpOutput = execSync("arp -a", { encoding: "utf8", timeout: 5000 }); 89 | } else { 90 | return arpMap; 91 | } 92 | 93 | const lines = arpOutput.split("\n"); 94 | for (const line of lines) { 95 | // Parse different ARP formats 96 | let match; 97 | if (platform === "darwin") { 98 | // macOS format: hostname (192.168.1.100) at aa:bb:cc:dd:ee:ff [ether] on en0 99 | match = line.match(/\((\d+\.\d+\.\d+\.\d+)\) at ([a-fA-F0-9:]{17})/); 100 | } else if (platform === "linux") { 101 | // Linux format: 192.168.1.100 ether aa:bb:cc:dd:ee:ff C eth0 102 | match = line.match(/(\d+\.\d+\.\d+\.\d+).*?([a-fA-F0-9:]{17})/); 103 | } else if (platform === "win32") { 104 | // Windows format: 192.168.1.100 aa-bb-cc-dd-ee-ff dynamic 105 | match = line.match(/(\d+\.\d+\.\d+\.\d+)\s+([a-fA-F0-9-]{17})/); 106 | if (match) { 107 | match[2] = match[2].replace(/-/g, ":"); // Convert Windows format to standard 108 | } 109 | } 110 | 111 | if (match) { 112 | arpMap.set(match[1], match[2].toLowerCase()); 113 | } 114 | } 115 | } catch (error) { 116 | console.error("[DEBUG] Failed to get ARP table:", error); 117 | } 118 | 119 | return arpMap; 120 | } 121 | 122 | private isAnkerEufyDevice(mac: string): boolean { 123 | const macPrefix = mac.toLowerCase().substring(0, 8); 124 | return this.ANKER_EUFY_OUIS.some((oui) => macPrefix.startsWith(oui)); 125 | } 126 | 127 | private async pingHost(ip: string): Promise<boolean> { 128 | try { 129 | const platform = os.platform(); 130 | let pingCommand: string; 131 | 132 | if (platform === "win32") { 133 | pingCommand = `ping -n 1 -w 1000 ${ip}`; 134 | } else { 135 | pingCommand = `ping -c 1 -W 1 ${ip}`; 136 | } 137 | 138 | execSync(pingCommand, { 139 | encoding: "utf8", 140 | timeout: 2000, 141 | stdio: "pipe", // Suppress output 142 | }); 143 | return true; 144 | } catch { 145 | return false; 146 | } 147 | } 148 | 149 | async discoverDevices(): Promise<NetworkDevice[]> { 150 | console.error("[DEBUG] Starting local network discovery..."); 151 | 152 | const networkRange = this.getLocalNetworkRange(); 153 | console.error(`[DEBUG] Scanning network range: ${networkRange}`); 154 | 155 | // Get current ARP table 156 | const arpTable = await this.getArpTable(); 157 | console.error(`[DEBUG] Found ${arpTable.size} devices in ARP table`); 158 | 159 | // Generate IP range to scan 160 | const baseIp = networkRange.split("/")[0]; 161 | const ipParts = baseIp.split("."); 162 | const ips: string[] = []; 163 | 164 | // Scan common ranges more efficiently 165 | for (let i = 1; i < 255; i++) { 166 | ips.push(`${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.${i}`); 167 | } 168 | 169 | const devices: NetworkDevice[] = []; 170 | const batchSize = 20; // Process IPs in batches to avoid overwhelming the network 171 | 172 | for (let i = 0; i < ips.length; i += batchSize) { 173 | const batch = ips.slice(i, i + batchSize); 174 | 175 | const batchPromises = batch.map(async (ip) => { 176 | // First check if device responds to ping 177 | const isAlive = await this.pingHost(ip); 178 | if (!isAlive) return null; 179 | 180 | console.error(`[DEBUG] Device found at ${ip}, checking ports...`); 181 | 182 | // Check Tuya/Eufy ports 183 | const portResults = await Promise.all( 184 | this.TUYA_PORTS.map((port) => this.scanPort(ip, port, 500)) 185 | ); 186 | 187 | const openPorts = this.TUYA_PORTS.filter( 188 | (port, index) => portResults[index] 189 | ); 190 | 191 | if (openPorts.length === 0) return null; 192 | 193 | const mac = arpTable.get(ip); 194 | const isAnkerDevice = mac ? this.isAnkerEufyDevice(mac) : false; 195 | 196 | const device: NetworkDevice = { 197 | ip, 198 | mac, 199 | vendor: isAnkerDevice ? "Anker/Eufy" : undefined, 200 | ports: openPorts, 201 | isLikelyRoboVac: isAnkerDevice && openPorts.includes(6668), 202 | }; 203 | 204 | console.error(`[DEBUG] Potential device: ${JSON.stringify(device)}`); 205 | return device; 206 | }); 207 | 208 | const batchResults = await Promise.all(batchPromises); 209 | const validDevices = batchResults.filter( 210 | (device): device is NetworkDevice => device !== null 211 | ); 212 | devices.push(...validDevices); 213 | 214 | // Progress indicator 215 | console.error( 216 | `[DEBUG] Scanned ${Math.min(i + batchSize, ips.length)}/${ 217 | ips.length 218 | } IPs...` 219 | ); 220 | } 221 | 222 | // Sort by likelihood of being a RoboVac 223 | devices.sort((a, b) => { 224 | if (a.isLikelyRoboVac && !b.isLikelyRoboVac) return -1; 225 | if (!a.isLikelyRoboVac && b.isLikelyRoboVac) return 1; 226 | return 0; 227 | }); 228 | 229 | console.error( 230 | `[DEBUG] Network discovery complete. Found ${devices.length} potential devices` 231 | ); 232 | return devices; 233 | } 234 | 235 | async findRoboVacs(): Promise<NetworkDevice[]> { 236 | const allDevices = await this.discoverDevices(); 237 | 238 | // Filter for devices that are likely RoboVacs 239 | return allDevices.filter( 240 | (device) => 241 | device.isLikelyRoboVac || 242 | (device.ports.includes(6668) && device.vendor === "Anker/Eufy") 243 | ); 244 | } 245 | } 246 | ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { 4 | CallToolRequestSchema, 5 | ListToolsRequestSchema, 6 | CallToolRequest, 7 | Tool, 8 | } from "@modelcontextprotocol/sdk/types.js"; 9 | import { RoboVac, WorkMode, CleanSpeed } from "eufy-robovac"; 10 | import { NetworkDiscovery } from "./network-discovery.js"; 11 | 12 | interface RoboVacConfig { 13 | deviceId: string; 14 | localKey: string; 15 | ip?: string; 16 | } 17 | 18 | class RoboVacMCPServer { 19 | private server: Server; 20 | private robovac: RoboVac | null = null; 21 | private networkDiscovery: NetworkDiscovery; 22 | 23 | constructor() { 24 | this.server = new Server( 25 | { 26 | name: "eufy-robovac-mcp-server", 27 | version: "0.2.0", 28 | }, 29 | { 30 | capabilities: { 31 | tools: { 32 | listChanged: false, 33 | }, 34 | logging: {}, 35 | }, 36 | } 37 | ); 38 | 39 | this.networkDiscovery = new NetworkDiscovery(); 40 | this.setupHandlers(); 41 | } 42 | 43 | private async initializeRoboVac( 44 | deviceId: string, 45 | localKey: string, 46 | ip?: string 47 | ): Promise<boolean> { 48 | try { 49 | this.robovac = new RoboVac({ 50 | deviceId: deviceId, 51 | localKey: localKey, 52 | ip: ip || "192.168.1.100", 53 | }); 54 | await this.robovac.connect(); 55 | return true; 56 | } catch (error) { 57 | console.error("Failed to initialize RoboVac:", error); 58 | return false; 59 | } 60 | } 61 | 62 | private ensureRoboVacInitialized(): void { 63 | if (!this.robovac) { 64 | throw new Error( 65 | "RoboVac not initialized. Please run robovac_auto_initialize or robovac_connect first." 66 | ); 67 | } 68 | } 69 | 70 | private async discoverBestRoboVacIP(): Promise<string | null> { 71 | try { 72 | console.error("[DEBUG] Auto-discovering RoboVac devices..."); 73 | const devices = await this.networkDiscovery.discoverDevices(); 74 | 75 | if (devices.length === 0) { 76 | console.error("[DEBUG] No devices found during auto-discovery"); 77 | return null; 78 | } 79 | 80 | // Filter for likely RoboVac devices 81 | const likelyRoboVacs = devices.filter((device) => device.isLikelyRoboVac); 82 | 83 | if (likelyRoboVacs.length > 0) { 84 | const bestDevice = likelyRoboVacs[0]; // Take the first likely device 85 | console.error( 86 | `[DEBUG] Found likely RoboVac at ${bestDevice.ip} (MAC: ${bestDevice.mac}, Vendor: ${bestDevice.vendor})` 87 | ); 88 | return bestDevice.ip; 89 | } 90 | 91 | // If no likely RoboVacs, try devices with port 6668 open 92 | const devicesWithPort6668 = devices.filter((device) => 93 | device.ports.includes(6668) 94 | ); 95 | 96 | if (devicesWithPort6668.length > 0) { 97 | const potentialDevice = devicesWithPort6668[0]; 98 | console.error( 99 | `[DEBUG] Found potential RoboVac at ${potentialDevice.ip} with port 6668 open` 100 | ); 101 | return potentialDevice.ip; 102 | } 103 | 104 | console.error("[DEBUG] No suitable RoboVac candidates found"); 105 | return null; 106 | } catch (error) { 107 | console.error( 108 | `[DEBUG] Auto-discovery failed: ${(error as Error).message}` 109 | ); 110 | return null; 111 | } 112 | } 113 | 114 | private setupHandlers(): void { 115 | this.server.setRequestHandler( 116 | ListToolsRequestSchema, 117 | async (): Promise<{ tools: Tool[] }> => ({ 118 | tools: [ 119 | { 120 | name: "robovac_set_work_mode", 121 | description: "Set the cleaning mode of the robovac", 122 | inputSchema: { 123 | type: "object", 124 | properties: { 125 | mode: { 126 | type: "string", 127 | description: "The work mode to set", 128 | enum: ["AUTO", "SMALL_ROOM", "SPOT", "EDGE", "NO_SWEEP"], 129 | }, 130 | }, 131 | required: ["mode"], 132 | }, 133 | }, 134 | { 135 | name: "robovac_set_clean_speed", 136 | description: "Set the suction speed of the robovac", 137 | inputSchema: { 138 | type: "object", 139 | properties: { 140 | speed: { 141 | type: "string", 142 | description: "The cleaning speed to set", 143 | enum: ["STANDARD", "BOOST_IQ", "MAX", "NO_SUCTION"], 144 | }, 145 | }, 146 | required: ["speed"], 147 | }, 148 | }, 149 | { 150 | name: "robovac_play", 151 | description: "Start/resume robovac cleaning", 152 | inputSchema: { 153 | type: "object", 154 | properties: {}, 155 | }, 156 | }, 157 | { 158 | name: "robovac_pause", 159 | description: "Pause robovac cleaning", 160 | inputSchema: { 161 | type: "object", 162 | properties: {}, 163 | }, 164 | }, 165 | { 166 | name: "robovac_find_robot", 167 | description: "Make the robovac beep to help locate it", 168 | inputSchema: { 169 | type: "object", 170 | properties: { 171 | enable: { 172 | type: "boolean", 173 | description: "Whether to enable or disable find robot mode", 174 | default: true, 175 | }, 176 | }, 177 | }, 178 | }, 179 | { 180 | name: "robovac_get_error_code", 181 | description: "Get the current error code of the robovac", 182 | inputSchema: { 183 | type: "object", 184 | properties: { 185 | force: { 186 | type: "boolean", 187 | description: "Force refresh of cached data", 188 | default: false, 189 | }, 190 | }, 191 | }, 192 | }, 193 | { 194 | name: "robovac_get_work_mode", 195 | description: "Get the current work mode of the robovac", 196 | inputSchema: { 197 | type: "object", 198 | properties: { 199 | force: { 200 | type: "boolean", 201 | description: "Force refresh of cached data", 202 | default: false, 203 | }, 204 | }, 205 | }, 206 | }, 207 | { 208 | name: "robovac_get_clean_speed", 209 | description: "Get the current cleaning speed of the robovac", 210 | inputSchema: { 211 | type: "object", 212 | properties: { 213 | force: { 214 | type: "boolean", 215 | description: "Force refresh of cached data", 216 | default: false, 217 | }, 218 | }, 219 | }, 220 | }, 221 | { 222 | name: "robovac_get_work_status", 223 | description: "Get the current work status of the robovac", 224 | inputSchema: { 225 | type: "object", 226 | properties: { 227 | force: { 228 | type: "boolean", 229 | description: "Force refresh of cached data", 230 | default: false, 231 | }, 232 | }, 233 | }, 234 | }, 235 | { 236 | name: "robovac_get_play_pause", 237 | description: "Get the current play/pause state of the robovac", 238 | inputSchema: { 239 | type: "object", 240 | properties: { 241 | force: { 242 | type: "boolean", 243 | description: "Force refresh of cached data", 244 | default: false, 245 | }, 246 | }, 247 | }, 248 | }, 249 | { 250 | name: "robovac_format_status", 251 | description: 252 | "Get a formatted display of all robovac status information", 253 | inputSchema: { 254 | type: "object", 255 | properties: {}, 256 | }, 257 | }, 258 | { 259 | name: "robovac_get_all_statuses", 260 | description: "Get all status information from the robovac at once", 261 | inputSchema: { 262 | type: "object", 263 | properties: { 264 | force: { 265 | type: "boolean", 266 | description: "Force refresh of cached data", 267 | default: false, 268 | }, 269 | }, 270 | }, 271 | }, 272 | { 273 | name: "robovac_auto_initialize", 274 | description: 275 | "Automatically discover and initialize the first RoboVac device found", 276 | inputSchema: { 277 | type: "object", 278 | properties: { 279 | email: { 280 | type: "string", 281 | description: "Your Eufy account email address", 282 | }, 283 | password: { 284 | type: "string", 285 | description: "Your Eufy account password", 286 | }, 287 | deviceIndex: { 288 | type: "number", 289 | description: 290 | "Index of device to connect to (0 for first device)", 291 | default: 0, 292 | }, 293 | }, 294 | required: ["email", "password"], 295 | }, 296 | }, 297 | { 298 | name: "robovac_connect_discovered", 299 | description: 300 | "Connect to a discovered RoboVac device by IP (requires device ID and local key)", 301 | inputSchema: { 302 | type: "object", 303 | properties: { 304 | ip: { 305 | type: "string", 306 | description: "IP address of the discovered device", 307 | }, 308 | deviceId: { 309 | type: "string", 310 | description: "The device ID of your Eufy RoboVac", 311 | }, 312 | localKey: { 313 | type: "string", 314 | description: "The local key for your Eufy RoboVac", 315 | }, 316 | }, 317 | required: ["ip", "deviceId", "localKey"], 318 | }, 319 | }, 320 | { 321 | name: "robovac_connect", 322 | description: 323 | "Connect to your RoboVac using device credentials (manual setup)", 324 | inputSchema: { 325 | type: "object", 326 | properties: { 327 | deviceId: { 328 | type: "string", 329 | description: "The device ID of your Eufy RoboVac", 330 | }, 331 | localKey: { 332 | type: "string", 333 | description: "The local key for your Eufy RoboVac", 334 | }, 335 | ip: { 336 | type: "string", 337 | description: 338 | "The IP address of your Eufy RoboVac (optional, defaults to 192.168.1.100)", 339 | }, 340 | }, 341 | required: ["deviceId", "localKey"], 342 | }, 343 | }, 344 | { 345 | name: "robovac_start_cleaning", 346 | description: "Start the robovac cleaning cycle", 347 | inputSchema: { 348 | type: "object", 349 | properties: {}, 350 | }, 351 | }, 352 | { 353 | name: "robovac_stop_cleaning", 354 | description: "Stop the robovac cleaning cycle", 355 | inputSchema: { 356 | type: "object", 357 | properties: {}, 358 | }, 359 | }, 360 | { 361 | name: "robovac_return_home", 362 | description: "Send the robovac back to its charging dock", 363 | inputSchema: { 364 | type: "object", 365 | properties: {}, 366 | }, 367 | }, 368 | { 369 | name: "robovac_get_status", 370 | description: "Get the current status of the robovac", 371 | inputSchema: { 372 | type: "object", 373 | properties: {}, 374 | }, 375 | }, 376 | { 377 | name: "robovac_get_battery", 378 | description: "Get the battery level of the robovac", 379 | inputSchema: { 380 | type: "object", 381 | properties: {}, 382 | }, 383 | }, 384 | ], 385 | }) 386 | ); 387 | 388 | this.server.setRequestHandler( 389 | CallToolRequestSchema, 390 | async (request: CallToolRequest) => { 391 | const { name, arguments: args } = request.params; 392 | 393 | try { 394 | switch (name) { 395 | case "robovac_connect_discovered": 396 | // Auto-discover best IP if not provided or if connection fails 397 | let targetIP = args?.ip as string; 398 | let discoveredSuccess = false; 399 | 400 | if (targetIP) { 401 | discoveredSuccess = await this.initializeRoboVac( 402 | args?.deviceId as string, 403 | args?.localKey as string, 404 | targetIP 405 | ); 406 | } 407 | 408 | if (!discoveredSuccess) { 409 | console.error( 410 | "[DEBUG] Initial connection failed or no IP provided, trying auto-discovery..." 411 | ); 412 | const discoveredIP = await this.discoverBestRoboVacIP(); 413 | 414 | if (discoveredIP) { 415 | targetIP = discoveredIP; 416 | discoveredSuccess = await this.initializeRoboVac( 417 | args?.deviceId as string, 418 | args?.localKey as string, 419 | targetIP 420 | ); 421 | } 422 | } 423 | 424 | return { 425 | content: [ 426 | { 427 | type: "text", 428 | text: discoveredSuccess 429 | ? `Successfully connected to RoboVac at ${targetIP}!` 430 | : `Failed to connect to device. ${ 431 | targetIP ? `Tried ${targetIP} but` : "" 432 | } Check your device ID and local key, and ensure the RoboVac is on the same network.`, 433 | }, 434 | ], 435 | isError: !discoveredSuccess, 436 | }; 437 | 438 | case "robovac_connect": 439 | // Auto-discover best IP if not provided or if connection fails 440 | let connectTargetIP = args?.ip as string | undefined; 441 | let connectSuccess = false; 442 | 443 | if (connectTargetIP) { 444 | connectSuccess = await this.initializeRoboVac( 445 | args?.deviceId as string, 446 | args?.localKey as string, 447 | connectTargetIP 448 | ); 449 | } 450 | 451 | if (!connectSuccess) { 452 | console.error( 453 | "[DEBUG] Manual connection failed or no IP provided, trying auto-discovery..." 454 | ); 455 | const discoveredIP = await this.discoverBestRoboVacIP(); 456 | 457 | if (discoveredIP) { 458 | connectTargetIP = discoveredIP; 459 | connectSuccess = await this.initializeRoboVac( 460 | args?.deviceId as string, 461 | args?.localKey as string, 462 | connectTargetIP 463 | ); 464 | } 465 | } 466 | 467 | // Fallback to default IP if still not successful 468 | if (!connectSuccess && !connectTargetIP) { 469 | connectTargetIP = "192.168.1.100"; 470 | connectSuccess = await this.initializeRoboVac( 471 | args?.deviceId as string, 472 | args?.localKey as string, 473 | connectTargetIP 474 | ); 475 | } 476 | 477 | return { 478 | content: [ 479 | { 480 | type: "text", 481 | text: connectSuccess 482 | ? `RoboVac connected successfully at ${connectTargetIP}!` 483 | : `Failed to connect to RoboVac. ${ 484 | connectTargetIP 485 | ? `Tried ${connectTargetIP} but connection failed.` 486 | : "" 487 | } Check your device ID, local key, and network connection.`, 488 | }, 489 | ], 490 | isError: !connectSuccess, 491 | }; 492 | 493 | case "robovac_auto_initialize": 494 | try { 495 | const devices = await this.networkDiscovery.discoverDevices(); 496 | 497 | if (devices.length === 0) { 498 | return { 499 | content: [ 500 | { 501 | type: "text", 502 | text: "No RoboVac devices found.", 503 | }, 504 | ], 505 | isError: true, 506 | }; 507 | } 508 | 509 | const deviceIndex = (args?.deviceIndex as number) || 0; 510 | if (deviceIndex >= devices.length) { 511 | return { 512 | content: [ 513 | { 514 | type: "text", 515 | text: `Device index ${deviceIndex} is out of range. Found ${devices.length} device(s).`, 516 | }, 517 | ], 518 | isError: true, 519 | }; 520 | } 521 | 522 | const selectedDevice = devices[deviceIndex]; 523 | let autoInitSuccess = await this.initializeRoboVac( 524 | selectedDevice.deviceId, 525 | selectedDevice.localKey, 526 | selectedDevice.ip 527 | ); 528 | 529 | // If direct connection fails, try auto-discovery 530 | if (!autoInitSuccess) { 531 | console.error( 532 | "[DEBUG] Cloud connection failed, trying auto-discovery..." 533 | ); 534 | const discoveredIP = await this.discoverBestRoboVacIP(); 535 | 536 | if (discoveredIP) { 537 | autoInitSuccess = await this.initializeRoboVac( 538 | selectedDevice.deviceId, 539 | selectedDevice.localKey, 540 | discoveredIP 541 | ); 542 | } 543 | } 544 | 545 | return { 546 | content: [ 547 | { 548 | type: "text", 549 | text: autoInitSuccess 550 | ? `Successfully connected to ${selectedDevice.name}!` 551 | : `Failed to connect to ${selectedDevice.name}. Check network connection and ensure the device is online.`, 552 | }, 553 | ], 554 | isError: !autoInitSuccess, 555 | }; 556 | } catch (error) { 557 | return { 558 | content: [ 559 | { 560 | type: "text", 561 | text: `Auto-initialization failed: ${ 562 | (error as Error).message 563 | } 564 | 565 | ⚠️ The Eufy API appears to have changed since this implementation was created. As an alternative, you can: 566 | 567 | 1. Use the Eufy app to find your device IP address 568 | 2. Use a network scanner to find devices on your network 569 | 3. Check your router's device list 570 | 4. Use tools like eufy-security-client or other community projects 571 | 572 | Once you have the device credentials, you can use the eufy-robovac library directly.`, 573 | }, 574 | ], 575 | isError: true, 576 | }; 577 | } 578 | 579 | case "robovac_start_cleaning": 580 | this.ensureRoboVacInitialized(); 581 | await this.robovac!.startCleaning(); 582 | return { 583 | content: [ 584 | { 585 | type: "text", 586 | text: "RoboVac cleaning started!", 587 | }, 588 | ], 589 | }; 590 | 591 | case "robovac_stop_cleaning": 592 | this.ensureRoboVacInitialized(); 593 | await this.robovac!.pause(); 594 | return { 595 | content: [ 596 | { 597 | type: "text", 598 | text: "RoboVac cleaning stopped!", 599 | }, 600 | ], 601 | }; 602 | 603 | case "robovac_return_home": 604 | this.ensureRoboVacInitialized(); 605 | await this.robovac!.goHome(); 606 | return { 607 | content: [ 608 | { 609 | type: "text", 610 | text: "RoboVac returning to charging dock!", 611 | }, 612 | ], 613 | }; 614 | 615 | case "robovac_get_status": 616 | this.ensureRoboVacInitialized(); 617 | const status = await this.robovac!.getStatuses(); 618 | return { 619 | content: [ 620 | { 621 | type: "text", 622 | text: `RoboVac Status:\n${JSON.stringify(status, null, 2)}`, 623 | }, 624 | ], 625 | }; 626 | 627 | case "robovac_get_battery": 628 | this.ensureRoboVacInitialized(); 629 | const battery = await this.robovac!.getBatteyLevel(); 630 | return { 631 | content: [ 632 | { 633 | type: "text", 634 | text: `Battery Level: ${battery}%`, 635 | }, 636 | ], 637 | }; 638 | 639 | case "robovac_set_work_mode": 640 | this.ensureRoboVacInitialized(); 641 | await this.robovac!.setWorkMode(args?.mode as WorkMode); 642 | return { 643 | content: [ 644 | { 645 | type: "text", 646 | text: `Work mode set to: ${args?.mode}`, 647 | }, 648 | ], 649 | }; 650 | 651 | case "robovac_set_clean_speed": 652 | this.ensureRoboVacInitialized(); 653 | await this.robovac!.setCleanSpeed(args?.speed as CleanSpeed); 654 | return { 655 | content: [ 656 | { 657 | type: "text", 658 | text: `Clean speed set to: ${args?.speed}`, 659 | }, 660 | ], 661 | }; 662 | 663 | case "robovac_play": 664 | this.ensureRoboVacInitialized(); 665 | await this.robovac!.play(); 666 | return { 667 | content: [ 668 | { 669 | type: "text", 670 | text: "RoboVac started/resumed cleaning!", 671 | }, 672 | ], 673 | }; 674 | 675 | case "robovac_pause": 676 | this.ensureRoboVacInitialized(); 677 | await this.robovac!.pause(); 678 | return { 679 | content: [ 680 | { 681 | type: "text", 682 | text: "RoboVac paused!", 683 | }, 684 | ], 685 | }; 686 | 687 | case "robovac_find_robot": 688 | this.ensureRoboVacInitialized(); 689 | const enableFind = 690 | args?.enable !== undefined ? (args?.enable as boolean) : true; 691 | await this.robovac!.setFindRobot(enableFind); 692 | return { 693 | content: [ 694 | { 695 | type: "text", 696 | text: enableFind 697 | ? "Find robot enabled - RoboVac should be beeping!" 698 | : "Find robot disabled", 699 | }, 700 | ], 701 | }; 702 | 703 | case "robovac_get_error_code": 704 | this.ensureRoboVacInitialized(); 705 | const errorCode = await this.robovac!.getErrorCode( 706 | args?.force as boolean 707 | ); 708 | return { 709 | content: [ 710 | { 711 | type: "text", 712 | text: `Error Code: ${errorCode}`, 713 | }, 714 | ], 715 | }; 716 | 717 | case "robovac_get_work_mode": 718 | this.ensureRoboVacInitialized(); 719 | const workMode = await this.robovac!.getWorkMode( 720 | args?.force as boolean 721 | ); 722 | return { 723 | content: [ 724 | { 725 | type: "text", 726 | text: `Work Mode: ${workMode}`, 727 | }, 728 | ], 729 | }; 730 | 731 | case "robovac_get_clean_speed": 732 | this.ensureRoboVacInitialized(); 733 | const cleanSpeed = await this.robovac!.getCleanSpeed( 734 | args?.force as boolean 735 | ); 736 | return { 737 | content: [ 738 | { 739 | type: "text", 740 | text: `Clean Speed: ${cleanSpeed}`, 741 | }, 742 | ], 743 | }; 744 | 745 | case "robovac_get_work_status": 746 | this.ensureRoboVacInitialized(); 747 | const workStatus = await this.robovac!.getWorkStatus( 748 | args?.force as boolean 749 | ); 750 | return { 751 | content: [ 752 | { 753 | type: "text", 754 | text: `Work Status: ${workStatus}`, 755 | }, 756 | ], 757 | }; 758 | 759 | case "robovac_get_play_pause": 760 | this.ensureRoboVacInitialized(); 761 | const playPause = await this.robovac!.getPlayPause( 762 | args?.force as boolean 763 | ); 764 | return { 765 | content: [ 766 | { 767 | type: "text", 768 | text: `Play/Pause State: ${playPause}`, 769 | }, 770 | ], 771 | }; 772 | 773 | case "robovac_format_status": 774 | this.ensureRoboVacInitialized(); 775 | await this.robovac!.formatStatus(); 776 | return { 777 | content: [ 778 | { 779 | type: "text", 780 | text: "Status information has been printed to console. Use robovac_get_all_statuses for structured data.", 781 | }, 782 | ], 783 | }; 784 | 785 | case "robovac_get_all_statuses": 786 | this.ensureRoboVacInitialized(); 787 | const allStatuses = await this.robovac!.getStatuses( 788 | args?.force as boolean 789 | ); 790 | return { 791 | content: [ 792 | { 793 | type: "text", 794 | text: `All RoboVac Statuses:\n${JSON.stringify( 795 | allStatuses, 796 | null, 797 | 2 798 | )}`, 799 | }, 800 | ], 801 | }; 802 | 803 | default: 804 | throw new Error(`Unknown tool: ${name}`); 805 | } 806 | } catch (error) { 807 | return { 808 | content: [ 809 | { 810 | type: "text", 811 | text: `Error: ${(error as Error).message}`, 812 | }, 813 | ], 814 | isError: true, 815 | }; 816 | } 817 | } 818 | ); 819 | } 820 | 821 | async run(): Promise<void> { 822 | const transport = new StdioServerTransport(); 823 | await this.server.connect(transport); 824 | console.error("Eufy RoboVac MCP server running on stdio"); 825 | } 826 | } 827 | 828 | const server = new RoboVacMCPServer(); 829 | server.run().catch(console.error); 830 | ```