#
tokens: 11632/50000 7/7 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```