mcp-android-emulator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # MCP Android Emulator
2
+
3
+ A Model Context Protocol (MCP) server that enables AI assistants like Claude to interact with Android devices and emulators via ADB (Android Debug Bridge).
4
+
5
+ ## Features
6
+
7
+ - **Screenshots**: Capture device screen as base64 images
8
+ - **UI Inspection**: Get UI hierarchy (like DOM but for Android)
9
+ - **Touch Input**: Tap, swipe, scroll gestures
10
+ - **Text Input**: Type text into input fields
11
+ - **System Keys**: Press BACK, HOME, ENTER, etc.
12
+ - **App Management**: Launch, install, force stop, clear data
13
+ - **Logs**: Access logcat with filters
14
+ - **Wait for Elements**: Poll UI for element appearance
15
+
16
+ ## Requirements
17
+
18
+ - Node.js 18+
19
+ - Android SDK with ADB installed
20
+ - Android emulator or physical device connected via ADB
21
+
22
+ ## Installation
23
+
24
+ ### From npm
25
+
26
+ ```bash
27
+ # Using npm
28
+ npm install -g mcp-android-emulator
29
+
30
+ # Using pnpm
31
+ pnpm add -g mcp-android-emulator
32
+
33
+ # Using yarn
34
+ yarn global add mcp-android-emulator
35
+ ```
36
+
37
+ ### From source
38
+
39
+ ```bash
40
+ git clone https://github.com/Anjos2/mcp-android-emulator.git
41
+ cd mcp-android-emulator
42
+ npm install
43
+ npm run build
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ ### Environment Variables
49
+
50
+ | Variable | Default | Description |
51
+ |----------|---------|-------------|
52
+ | `ADB_PATH` | `adb` | Path to ADB executable |
53
+ | `SCREENSHOT_DIR` | `/tmp/android-screenshots` | Directory for temporary screenshots |
54
+
55
+ ### Claude Code Integration
56
+
57
+ Add to your Claude Code configuration:
58
+
59
+ ```bash
60
+ claude mcp add android-emulator npx mcp-android-emulator
61
+ ```
62
+
63
+ Or manually edit `~/.claude.json`:
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "android-emulator": {
69
+ "command": "npx",
70
+ "args": ["mcp-android-emulator"],
71
+ "env": {
72
+ "ADB_PATH": "/path/to/adb"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ ### Claude Desktop Integration
80
+
81
+ Add to `claude_desktop_config.json`:
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "android-emulator": {
87
+ "command": "npx",
88
+ "args": ["mcp-android-emulator"],
89
+ "env": {
90
+ "ADB_PATH": "/path/to/adb"
91
+ }
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ## Available Tools
98
+
99
+ ### Screen Interaction
100
+
101
+ | Tool | Description |
102
+ |------|-------------|
103
+ | `screenshot` | Capture screen as base64 PNG image |
104
+ | `get_ui_tree` | Get UI element hierarchy with coordinates |
105
+ | `tap` | Tap at specific coordinates |
106
+ | `tap_text` | Find element by text and tap it |
107
+ | `type_text` | Type text into focused input |
108
+ | `swipe` | Swipe between two points |
109
+ | `scroll` | Scroll in a direction (up/down/left/right) |
110
+ | `press_key` | Press system key (BACK, HOME, ENTER, etc.) |
111
+
112
+ ### App Management
113
+
114
+ | Tool | Description |
115
+ |------|-------------|
116
+ | `launch_app` | Launch app by package name |
117
+ | `install_apk` | Install APK file |
118
+ | `list_packages` | List installed packages |
119
+ | `clear_app_data` | Clear app data |
120
+ | `force_stop` | Force stop an app |
121
+
122
+ ### Device Info & Logs
123
+
124
+ | Tool | Description |
125
+ |------|-------------|
126
+ | `device_info` | Get device model, Android version, screen size |
127
+ | `get_logs` | Get logcat logs with optional filters |
128
+ | `get_current_activity` | Get currently focused activity |
129
+
130
+ ### Utilities
131
+
132
+ | Tool | Description |
133
+ |------|-------------|
134
+ | `wait_for_element` | Wait for UI element to appear |
135
+
136
+ ## Usage Examples
137
+
138
+ Once configured, you can ask Claude to:
139
+
140
+ ```
141
+ "Take a screenshot of the Android emulator"
142
+
143
+ "Tap on the Login button"
144
+
145
+ "Type 'hello@example.com' in the email field"
146
+
147
+ "Scroll down and find the Submit button"
148
+
149
+ "Launch the Chrome app"
150
+
151
+ "Get the logs from the last minute filtered by 'error'"
152
+ ```
153
+
154
+ ## Running Android Emulator Headless
155
+
156
+ For server environments without a display:
157
+
158
+ ```bash
159
+ emulator -avd YOUR_AVD_NAME \
160
+ -no-window \
161
+ -no-audio \
162
+ -no-boot-anim \
163
+ -gpu swiftshader_indirect \
164
+ -memory 2048 \
165
+ -cores 2
166
+ ```
167
+
168
+ ## Troubleshooting
169
+
170
+ ### ADB not found
171
+
172
+ Set the `ADB_PATH` environment variable:
173
+
174
+ ```bash
175
+ export ADB_PATH=/path/to/android-sdk/platform-tools/adb
176
+ ```
177
+
178
+ ### No devices connected
179
+
180
+ Check device connection:
181
+
182
+ ```bash
183
+ adb devices
184
+ ```
185
+
186
+ For emulators, ensure the emulator is running and booted:
187
+
188
+ ```bash
189
+ adb shell getprop sys.boot_completed # Should return "1"
190
+ ```
191
+
192
+ ### Permission denied on screenshots
193
+
194
+ Ensure the screenshot directory is writable:
195
+
196
+ ```bash
197
+ mkdir -p /tmp/android-screenshots
198
+ chmod 755 /tmp/android-screenshots
199
+ ```
200
+
201
+ ## Development
202
+
203
+ ```bash
204
+ # Install dependencies
205
+ npm install
206
+
207
+ # Build
208
+ npm run build
209
+
210
+ # Watch mode
211
+ npm run dev
212
+ ```
213
+
214
+ ## License
215
+
216
+ MIT License - see [LICENSE](LICENSE) for details.
217
+
218
+ ## Contributing
219
+
220
+ Contributions are welcome! Please feel free to submit a Pull Request.
221
+
222
+ ## Related Projects
223
+
224
+ - [Model Context Protocol](https://modelcontextprotocol.io/)
225
+ - [Android Debug Bridge (ADB)](https://developer.android.com/tools/adb)
226
+ - [Claude Code](https://claude.ai/claude-code)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Server for Android Emulator
4
+ * Enables AI assistants to interact with Android devices/emulators via ADB
5
+ *
6
+ * @license MIT
7
+ */
8
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Server for Android Emulator
4
+ * Enables AI assistants to interact with Android devices/emulators via ADB
5
+ *
6
+ * @license MIT
7
+ */
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { z } from "zod";
11
+ import { execSync, exec } from "child_process";
12
+ import { promisify } from "util";
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ const execAsync = promisify(exec);
16
+ // Configuration
17
+ const ADB_PATH = process.env.ADB_PATH || "adb";
18
+ const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR || "/tmp/android-screenshots";
19
+ // Create screenshot directory if it doesn't exist
20
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
21
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
22
+ }
23
+ /**
24
+ * Execute an ADB command
25
+ */
26
+ async function adb(command) {
27
+ try {
28
+ const { stdout } = await execAsync(`${ADB_PATH} ${command}`);
29
+ return stdout.trim();
30
+ }
31
+ catch (error) {
32
+ throw new Error(`ADB Error: ${error.message}`);
33
+ }
34
+ }
35
+ /**
36
+ * Execute a shell command on the device
37
+ */
38
+ async function shell(command) {
39
+ return adb(`shell ${command}`);
40
+ }
41
+ // Create MCP server
42
+ const server = new McpServer({
43
+ name: "android-emulator",
44
+ version: "1.0.0",
45
+ });
46
+ // =====================================================
47
+ // TOOL: screenshot
48
+ // =====================================================
49
+ server.tool("screenshot", "Take a screenshot of the Android device/emulator and return it as a base64 image", {}, async () => {
50
+ const filename = `screenshot_${Date.now()}.png`;
51
+ const filepath = path.join(SCREENSHOT_DIR, filename);
52
+ // Capture screenshot
53
+ execSync(`${ADB_PATH} exec-out screencap -p > ${filepath}`);
54
+ // Read as base64
55
+ const imageBuffer = fs.readFileSync(filepath);
56
+ const base64 = imageBuffer.toString("base64");
57
+ // Clean up temp file
58
+ fs.unlinkSync(filepath);
59
+ return {
60
+ content: [
61
+ {
62
+ type: "image",
63
+ data: base64,
64
+ mimeType: "image/png",
65
+ },
66
+ ],
67
+ };
68
+ });
69
+ // =====================================================
70
+ // TOOL: get_ui_tree
71
+ // =====================================================
72
+ server.tool("get_ui_tree", "Get the UI element tree of the device (like DOM but for Android). Returns clickable elements with their coordinates.", {}, async () => {
73
+ // Dump UI hierarchy
74
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
75
+ const xml = await shell("cat /sdcard/ui_dump.xml");
76
+ // Parse clickable elements
77
+ const elements = [];
78
+ const regex = /text="([^"]*)".*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g;
79
+ let match;
80
+ while ((match = regex.exec(xml)) !== null) {
81
+ const [, text, x1, y1, x2, y2] = match;
82
+ if (text) {
83
+ const centerX = Math.round((parseInt(x1) + parseInt(x2)) / 2);
84
+ const centerY = Math.round((parseInt(y1) + parseInt(y2)) / 2);
85
+ elements.push(`"${text}" at (${centerX}, ${centerY})`);
86
+ }
87
+ }
88
+ return {
89
+ content: [
90
+ {
91
+ type: "text",
92
+ text: `Elements found:\n${elements.join("\n")}\n\nFull XML:\n${xml.substring(0, 5000)}...`,
93
+ },
94
+ ],
95
+ };
96
+ });
97
+ // =====================================================
98
+ // TOOL: tap
99
+ // =====================================================
100
+ server.tool("tap", "Tap at the specified coordinates on the screen", {
101
+ x: z.number().describe("X coordinate"),
102
+ y: z.number().describe("Y coordinate"),
103
+ }, async ({ x, y }) => {
104
+ await shell(`input tap ${x} ${y}`);
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: `Tapped at (${x}, ${y})`,
110
+ },
111
+ ],
112
+ };
113
+ });
114
+ // =====================================================
115
+ // TOOL: tap_text
116
+ // =====================================================
117
+ server.tool("tap_text", "Find an element by its text content and tap on it", {
118
+ text: z.string().describe("Text of the element to find and tap"),
119
+ exact: z.boolean().optional().describe("If true, match exact text. Default: false (partial match)"),
120
+ }, async ({ text, exact = false }) => {
121
+ // Dump UI hierarchy
122
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
123
+ const xml = await shell("cat /sdcard/ui_dump.xml");
124
+ // Build regex based on exact match preference
125
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ const pattern = exact
127
+ ? `text="${escapedText}".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`
128
+ : `text="[^"]*${escapedText}[^"]*".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`;
129
+ const regex = new RegExp(pattern, "i");
130
+ const match = regex.exec(xml);
131
+ if (!match) {
132
+ return {
133
+ content: [
134
+ {
135
+ type: "text",
136
+ text: `Element with text "${text}" not found`,
137
+ },
138
+ ],
139
+ };
140
+ }
141
+ const [, x1, y1, x2, y2] = match;
142
+ const centerX = Math.round((parseInt(x1) + parseInt(x2)) / 2);
143
+ const centerY = Math.round((parseInt(y1) + parseInt(y2)) / 2);
144
+ await shell(`input tap ${centerX} ${centerY}`);
145
+ return {
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: `Tapped on "${text}" at (${centerX}, ${centerY})`,
150
+ },
151
+ ],
152
+ };
153
+ });
154
+ // =====================================================
155
+ // TOOL: type_text
156
+ // =====================================================
157
+ server.tool("type_text", "Type text into the currently focused input field", {
158
+ text: z.string().describe("Text to type"),
159
+ }, async ({ text }) => {
160
+ // Escape special characters for shell
161
+ const escaped = text.replace(/ /g, "%s").replace(/'/g, "\\'");
162
+ await shell(`input text "${escaped}"`);
163
+ return {
164
+ content: [
165
+ {
166
+ type: "text",
167
+ text: `Typed: "${text}"`,
168
+ },
169
+ ],
170
+ };
171
+ });
172
+ // =====================================================
173
+ // TOOL: swipe
174
+ // =====================================================
175
+ server.tool("swipe", "Perform a swipe gesture on the screen", {
176
+ x1: z.number().describe("Starting X coordinate"),
177
+ y1: z.number().describe("Starting Y coordinate"),
178
+ x2: z.number().describe("Ending X coordinate"),
179
+ y2: z.number().describe("Ending Y coordinate"),
180
+ duration: z.number().optional().describe("Duration in milliseconds (default: 300)"),
181
+ }, async ({ x1, y1, x2, y2, duration = 300 }) => {
182
+ await shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${duration}`);
183
+ return {
184
+ content: [
185
+ {
186
+ type: "text",
187
+ text: `Swiped from (${x1}, ${y1}) to (${x2}, ${y2})`,
188
+ },
189
+ ],
190
+ };
191
+ });
192
+ // =====================================================
193
+ // TOOL: scroll
194
+ // =====================================================
195
+ server.tool("scroll", "Scroll the screen in a direction", {
196
+ direction: z.enum(["up", "down", "left", "right"]).describe("Direction to scroll"),
197
+ amount: z.number().optional().describe("Scroll amount in pixels (default: 500)"),
198
+ }, async ({ direction, amount = 500 }) => {
199
+ // Get screen dimensions for centering the scroll
200
+ const sizeOutput = await shell("wm size");
201
+ const sizeMatch = sizeOutput.match(/(\d+)x(\d+)/);
202
+ const width = sizeMatch ? parseInt(sizeMatch[1]) : 1080;
203
+ const height = sizeMatch ? parseInt(sizeMatch[2]) : 2400;
204
+ const centerX = Math.round(width / 2);
205
+ const centerY = Math.round(height / 2);
206
+ let x1 = centerX, y1 = centerY, x2 = centerX, y2 = centerY;
207
+ switch (direction) {
208
+ case "up":
209
+ y1 = centerY + amount / 2;
210
+ y2 = centerY - amount / 2;
211
+ break;
212
+ case "down":
213
+ y1 = centerY - amount / 2;
214
+ y2 = centerY + amount / 2;
215
+ break;
216
+ case "left":
217
+ x1 = centerX + amount / 2;
218
+ x2 = centerX - amount / 2;
219
+ break;
220
+ case "right":
221
+ x1 = centerX - amount / 2;
222
+ x2 = centerX + amount / 2;
223
+ break;
224
+ }
225
+ await shell(`input swipe ${x1} ${y1} ${x2} ${y2} 300`);
226
+ return {
227
+ content: [
228
+ {
229
+ type: "text",
230
+ text: `Scrolled ${direction}`,
231
+ },
232
+ ],
233
+ };
234
+ });
235
+ // =====================================================
236
+ // TOOL: press_key
237
+ // =====================================================
238
+ server.tool("press_key", "Press a system key (BACK, HOME, ENTER, etc)", {
239
+ key: z.enum(["BACK", "HOME", "ENTER", "TAB", "DELETE", "MENU", "POWER", "VOLUME_UP", "VOLUME_DOWN"]).describe("Key to press"),
240
+ }, async ({ key }) => {
241
+ const keycodes = {
242
+ BACK: 4,
243
+ HOME: 3,
244
+ ENTER: 66,
245
+ TAB: 61,
246
+ DELETE: 67,
247
+ MENU: 82,
248
+ POWER: 26,
249
+ VOLUME_UP: 24,
250
+ VOLUME_DOWN: 25,
251
+ };
252
+ await shell(`input keyevent ${keycodes[key]}`);
253
+ return {
254
+ content: [
255
+ {
256
+ type: "text",
257
+ text: `Pressed ${key} key`,
258
+ },
259
+ ],
260
+ };
261
+ });
262
+ // =====================================================
263
+ // TOOL: launch_app
264
+ // =====================================================
265
+ server.tool("launch_app", "Launch an application by its package name", {
266
+ package: z.string().describe("Package name of the app (e.g., com.android.chrome)"),
267
+ }, async ({ package: pkg }) => {
268
+ await shell(`monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`);
269
+ return {
270
+ content: [
271
+ {
272
+ type: "text",
273
+ text: `Launched ${pkg}`,
274
+ },
275
+ ],
276
+ };
277
+ });
278
+ // =====================================================
279
+ // TOOL: install_apk
280
+ // =====================================================
281
+ server.tool("install_apk", "Install an APK file on the device", {
282
+ path: z.string().describe("Path to the APK file"),
283
+ }, async ({ path: apkPath }) => {
284
+ const result = await adb(`install -r ${apkPath}`);
285
+ return {
286
+ content: [
287
+ {
288
+ type: "text",
289
+ text: `APK installed: ${result}`,
290
+ },
291
+ ],
292
+ };
293
+ });
294
+ // =====================================================
295
+ // TOOL: list_packages
296
+ // =====================================================
297
+ server.tool("list_packages", "List installed packages on the device", {
298
+ filter: z.string().optional().describe("Filter packages by name (optional)"),
299
+ }, async ({ filter }) => {
300
+ let cmd = "pm list packages";
301
+ if (filter) {
302
+ cmd += ` | grep -i "${filter}"`;
303
+ }
304
+ const result = await shell(cmd);
305
+ const packages = result.split("\n").map((p) => p.replace("package:", "")).filter(Boolean);
306
+ return {
307
+ content: [
308
+ {
309
+ type: "text",
310
+ text: `Installed packages:\n${packages.join("\n")}`,
311
+ },
312
+ ],
313
+ };
314
+ });
315
+ // =====================================================
316
+ // TOOL: get_logs
317
+ // =====================================================
318
+ server.tool("get_logs", "Get device logs (logcat)", {
319
+ filter: z.string().optional().describe("Filter logs by tag or keyword"),
320
+ lines: z.number().optional().describe("Number of lines to retrieve (default: 50)"),
321
+ level: z.enum(["V", "D", "I", "W", "E"]).optional().describe("Minimum log level (V=Verbose, D=Debug, I=Info, W=Warn, E=Error)"),
322
+ }, async ({ filter, lines = 50, level }) => {
323
+ let cmd = `logcat -d -t ${lines}`;
324
+ if (level) {
325
+ cmd += ` *:${level}`;
326
+ }
327
+ if (filter) {
328
+ cmd += ` | grep -i "${filter}"`;
329
+ }
330
+ const logs = await shell(cmd);
331
+ return {
332
+ content: [
333
+ {
334
+ type: "text",
335
+ text: `Logs:\n${logs}`,
336
+ },
337
+ ],
338
+ };
339
+ });
340
+ // =====================================================
341
+ // TOOL: device_info
342
+ // =====================================================
343
+ server.tool("device_info", "Get information about the connected device", {}, async () => {
344
+ const [model, android, sdk, density, size, battery] = await Promise.all([
345
+ shell("getprop ro.product.model"),
346
+ shell("getprop ro.build.version.release"),
347
+ shell("getprop ro.build.version.sdk"),
348
+ shell("wm density"),
349
+ shell("wm size"),
350
+ shell("dumpsys battery | grep level"),
351
+ ]);
352
+ return {
353
+ content: [
354
+ {
355
+ type: "text",
356
+ text: `Device: ${model}
357
+ Android: ${android} (SDK ${sdk})
358
+ Screen: ${size.replace("Physical size: ", "")}
359
+ Density: ${density.replace("Physical density: ", "")}
360
+ Battery: ${battery.replace("level: ", "")}%`,
361
+ },
362
+ ],
363
+ };
364
+ });
365
+ // =====================================================
366
+ // TOOL: clear_app_data
367
+ // =====================================================
368
+ server.tool("clear_app_data", "Clear all data for an application", {
369
+ package: z.string().describe("Package name of the app"),
370
+ }, async ({ package: pkg }) => {
371
+ await shell(`pm clear ${pkg}`);
372
+ return {
373
+ content: [
374
+ {
375
+ type: "text",
376
+ text: `Data cleared for ${pkg}`,
377
+ },
378
+ ],
379
+ };
380
+ });
381
+ // =====================================================
382
+ // TOOL: force_stop
383
+ // =====================================================
384
+ server.tool("force_stop", "Force stop an application", {
385
+ package: z.string().describe("Package name of the app"),
386
+ }, async ({ package: pkg }) => {
387
+ await shell(`am force-stop ${pkg}`);
388
+ return {
389
+ content: [
390
+ {
391
+ type: "text",
392
+ text: `Force stopped ${pkg}`,
393
+ },
394
+ ],
395
+ };
396
+ });
397
+ // =====================================================
398
+ // TOOL: get_current_activity
399
+ // =====================================================
400
+ server.tool("get_current_activity", "Get the currently focused activity/screen", {}, async () => {
401
+ const result = await shell("dumpsys activity activities | grep mResumedActivity");
402
+ return {
403
+ content: [
404
+ {
405
+ type: "text",
406
+ text: `Current activity: ${result.trim()}`,
407
+ },
408
+ ],
409
+ };
410
+ });
411
+ // =====================================================
412
+ // TOOL: wait_for_element
413
+ // =====================================================
414
+ server.tool("wait_for_element", "Wait for a UI element with specific text to appear", {
415
+ text: z.string().describe("Text of the element to wait for"),
416
+ timeout: z.number().optional().describe("Timeout in seconds (default: 10)"),
417
+ }, async ({ text, timeout = 10 }) => {
418
+ const startTime = Date.now();
419
+ const timeoutMs = timeout * 1000;
420
+ while (Date.now() - startTime < timeoutMs) {
421
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
422
+ const xml = await shell("cat /sdcard/ui_dump.xml");
423
+ if (xml.toLowerCase().includes(text.toLowerCase())) {
424
+ return {
425
+ content: [
426
+ {
427
+ type: "text",
428
+ text: `Element "${text}" found after ${Math.round((Date.now() - startTime) / 1000)}s`,
429
+ },
430
+ ],
431
+ };
432
+ }
433
+ // Wait 500ms before next check
434
+ await new Promise((resolve) => setTimeout(resolve, 500));
435
+ }
436
+ return {
437
+ content: [
438
+ {
439
+ type: "text",
440
+ text: `Timeout: Element "${text}" not found after ${timeout}s`,
441
+ },
442
+ ],
443
+ };
444
+ });
445
+ // Start server
446
+ async function main() {
447
+ const transport = new StdioServerTransport();
448
+ await server.connect(transport);
449
+ console.error("MCP Android Emulator Server running on stdio");
450
+ }
451
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "mcp-android-emulator",
3
+ "version": "1.0.0",
4
+ "description": "MCP Server for Android Emulator interaction via ADB - enables AI assistants to control Android devices",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-android-emulator": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "dev": "tsc --watch",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "android",
20
+ "emulator",
21
+ "adb",
22
+ "automation",
23
+ "testing",
24
+ "claude",
25
+ "ai"
26
+ ],
27
+ "author": "",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Anjos2/mcp-android-emulator.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/Anjos2/mcp-android-emulator/issues"
35
+ },
36
+ "homepage": "https://github.com/Anjos2/mcp-android-emulator#readme",
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.0.0",
42
+ "zod": "^3.23.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^20.0.0",
46
+ "typescript": "^5.0.0"
47
+ }
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Server for Android Emulator
4
+ * Enables AI assistants to interact with Android devices/emulators via ADB
5
+ *
6
+ * @license MIT
7
+ */
8
+
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { z } from "zod";
12
+ import { execSync, exec } from "child_process";
13
+ import { promisify } from "util";
14
+ import * as fs from "fs";
15
+ import * as path from "path";
16
+
17
+ const execAsync = promisify(exec);
18
+
19
+ // Configuration
20
+ const ADB_PATH = process.env.ADB_PATH || "adb";
21
+ const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR || "/tmp/android-screenshots";
22
+
23
+ // Create screenshot directory if it doesn't exist
24
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
25
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
26
+ }
27
+
28
+ /**
29
+ * Execute an ADB command
30
+ */
31
+ async function adb(command: string): Promise<string> {
32
+ try {
33
+ const { stdout } = await execAsync(`${ADB_PATH} ${command}`);
34
+ return stdout.trim();
35
+ } catch (error: any) {
36
+ throw new Error(`ADB Error: ${error.message}`);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Execute a shell command on the device
42
+ */
43
+ async function shell(command: string): Promise<string> {
44
+ return adb(`shell ${command}`);
45
+ }
46
+
47
+ // Create MCP server
48
+ const server = new McpServer({
49
+ name: "android-emulator",
50
+ version: "1.0.0",
51
+ });
52
+
53
+ // =====================================================
54
+ // TOOL: screenshot
55
+ // =====================================================
56
+ server.tool(
57
+ "screenshot",
58
+ "Take a screenshot of the Android device/emulator and return it as a base64 image",
59
+ {},
60
+ async () => {
61
+ const filename = `screenshot_${Date.now()}.png`;
62
+ const filepath = path.join(SCREENSHOT_DIR, filename);
63
+
64
+ // Capture screenshot
65
+ execSync(`${ADB_PATH} exec-out screencap -p > ${filepath}`);
66
+
67
+ // Read as base64
68
+ const imageBuffer = fs.readFileSync(filepath);
69
+ const base64 = imageBuffer.toString("base64");
70
+
71
+ // Clean up temp file
72
+ fs.unlinkSync(filepath);
73
+
74
+ return {
75
+ content: [
76
+ {
77
+ type: "image",
78
+ data: base64,
79
+ mimeType: "image/png",
80
+ },
81
+ ],
82
+ };
83
+ }
84
+ );
85
+
86
+ // =====================================================
87
+ // TOOL: get_ui_tree
88
+ // =====================================================
89
+ server.tool(
90
+ "get_ui_tree",
91
+ "Get the UI element tree of the device (like DOM but for Android). Returns clickable elements with their coordinates.",
92
+ {},
93
+ async () => {
94
+ // Dump UI hierarchy
95
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
96
+ const xml = await shell("cat /sdcard/ui_dump.xml");
97
+
98
+ // Parse clickable elements
99
+ const elements: string[] = [];
100
+ const regex = /text="([^"]*)".*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g;
101
+ let match;
102
+
103
+ while ((match = regex.exec(xml)) !== null) {
104
+ const [, text, x1, y1, x2, y2] = match;
105
+ if (text) {
106
+ const centerX = Math.round((parseInt(x1) + parseInt(x2)) / 2);
107
+ const centerY = Math.round((parseInt(y1) + parseInt(y2)) / 2);
108
+ elements.push(`"${text}" at (${centerX}, ${centerY})`);
109
+ }
110
+ }
111
+
112
+ return {
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: `Elements found:\n${elements.join("\n")}\n\nFull XML:\n${xml.substring(0, 5000)}...`,
117
+ },
118
+ ],
119
+ };
120
+ }
121
+ );
122
+
123
+ // =====================================================
124
+ // TOOL: tap
125
+ // =====================================================
126
+ server.tool(
127
+ "tap",
128
+ "Tap at the specified coordinates on the screen",
129
+ {
130
+ x: z.number().describe("X coordinate"),
131
+ y: z.number().describe("Y coordinate"),
132
+ },
133
+ async ({ x, y }) => {
134
+ await shell(`input tap ${x} ${y}`);
135
+ return {
136
+ content: [
137
+ {
138
+ type: "text",
139
+ text: `Tapped at (${x}, ${y})`,
140
+ },
141
+ ],
142
+ };
143
+ }
144
+ );
145
+
146
+ // =====================================================
147
+ // TOOL: tap_text
148
+ // =====================================================
149
+ server.tool(
150
+ "tap_text",
151
+ "Find an element by its text content and tap on it",
152
+ {
153
+ text: z.string().describe("Text of the element to find and tap"),
154
+ exact: z.boolean().optional().describe("If true, match exact text. Default: false (partial match)"),
155
+ },
156
+ async ({ text, exact = false }) => {
157
+ // Dump UI hierarchy
158
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
159
+ const xml = await shell("cat /sdcard/ui_dump.xml");
160
+
161
+ // Build regex based on exact match preference
162
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
+ const pattern = exact
164
+ ? `text="${escapedText}".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`
165
+ : `text="[^"]*${escapedText}[^"]*".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`;
166
+
167
+ const regex = new RegExp(pattern, "i");
168
+ const match = regex.exec(xml);
169
+
170
+ if (!match) {
171
+ return {
172
+ content: [
173
+ {
174
+ type: "text",
175
+ text: `Element with text "${text}" not found`,
176
+ },
177
+ ],
178
+ };
179
+ }
180
+
181
+ const [, x1, y1, x2, y2] = match;
182
+ const centerX = Math.round((parseInt(x1) + parseInt(x2)) / 2);
183
+ const centerY = Math.round((parseInt(y1) + parseInt(y2)) / 2);
184
+
185
+ await shell(`input tap ${centerX} ${centerY}`);
186
+
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: `Tapped on "${text}" at (${centerX}, ${centerY})`,
192
+ },
193
+ ],
194
+ };
195
+ }
196
+ );
197
+
198
+ // =====================================================
199
+ // TOOL: type_text
200
+ // =====================================================
201
+ server.tool(
202
+ "type_text",
203
+ "Type text into the currently focused input field",
204
+ {
205
+ text: z.string().describe("Text to type"),
206
+ },
207
+ async ({ text }) => {
208
+ // Escape special characters for shell
209
+ const escaped = text.replace(/ /g, "%s").replace(/'/g, "\\'");
210
+ await shell(`input text "${escaped}"`);
211
+
212
+ return {
213
+ content: [
214
+ {
215
+ type: "text",
216
+ text: `Typed: "${text}"`,
217
+ },
218
+ ],
219
+ };
220
+ }
221
+ );
222
+
223
+ // =====================================================
224
+ // TOOL: swipe
225
+ // =====================================================
226
+ server.tool(
227
+ "swipe",
228
+ "Perform a swipe gesture on the screen",
229
+ {
230
+ x1: z.number().describe("Starting X coordinate"),
231
+ y1: z.number().describe("Starting Y coordinate"),
232
+ x2: z.number().describe("Ending X coordinate"),
233
+ y2: z.number().describe("Ending Y coordinate"),
234
+ duration: z.number().optional().describe("Duration in milliseconds (default: 300)"),
235
+ },
236
+ async ({ x1, y1, x2, y2, duration = 300 }) => {
237
+ await shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${duration}`);
238
+
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text",
243
+ text: `Swiped from (${x1}, ${y1}) to (${x2}, ${y2})`,
244
+ },
245
+ ],
246
+ };
247
+ }
248
+ );
249
+
250
+ // =====================================================
251
+ // TOOL: scroll
252
+ // =====================================================
253
+ server.tool(
254
+ "scroll",
255
+ "Scroll the screen in a direction",
256
+ {
257
+ direction: z.enum(["up", "down", "left", "right"]).describe("Direction to scroll"),
258
+ amount: z.number().optional().describe("Scroll amount in pixels (default: 500)"),
259
+ },
260
+ async ({ direction, amount = 500 }) => {
261
+ // Get screen dimensions for centering the scroll
262
+ const sizeOutput = await shell("wm size");
263
+ const sizeMatch = sizeOutput.match(/(\d+)x(\d+)/);
264
+ const width = sizeMatch ? parseInt(sizeMatch[1]) : 1080;
265
+ const height = sizeMatch ? parseInt(sizeMatch[2]) : 2400;
266
+
267
+ const centerX = Math.round(width / 2);
268
+ const centerY = Math.round(height / 2);
269
+
270
+ let x1 = centerX, y1 = centerY, x2 = centerX, y2 = centerY;
271
+
272
+ switch (direction) {
273
+ case "up":
274
+ y1 = centerY + amount / 2;
275
+ y2 = centerY - amount / 2;
276
+ break;
277
+ case "down":
278
+ y1 = centerY - amount / 2;
279
+ y2 = centerY + amount / 2;
280
+ break;
281
+ case "left":
282
+ x1 = centerX + amount / 2;
283
+ x2 = centerX - amount / 2;
284
+ break;
285
+ case "right":
286
+ x1 = centerX - amount / 2;
287
+ x2 = centerX + amount / 2;
288
+ break;
289
+ }
290
+
291
+ await shell(`input swipe ${x1} ${y1} ${x2} ${y2} 300`);
292
+
293
+ return {
294
+ content: [
295
+ {
296
+ type: "text",
297
+ text: `Scrolled ${direction}`,
298
+ },
299
+ ],
300
+ };
301
+ }
302
+ );
303
+
304
+ // =====================================================
305
+ // TOOL: press_key
306
+ // =====================================================
307
+ server.tool(
308
+ "press_key",
309
+ "Press a system key (BACK, HOME, ENTER, etc)",
310
+ {
311
+ key: z.enum(["BACK", "HOME", "ENTER", "TAB", "DELETE", "MENU", "POWER", "VOLUME_UP", "VOLUME_DOWN"]).describe("Key to press"),
312
+ },
313
+ async ({ key }) => {
314
+ const keycodes: Record<string, number> = {
315
+ BACK: 4,
316
+ HOME: 3,
317
+ ENTER: 66,
318
+ TAB: 61,
319
+ DELETE: 67,
320
+ MENU: 82,
321
+ POWER: 26,
322
+ VOLUME_UP: 24,
323
+ VOLUME_DOWN: 25,
324
+ };
325
+
326
+ await shell(`input keyevent ${keycodes[key]}`);
327
+
328
+ return {
329
+ content: [
330
+ {
331
+ type: "text",
332
+ text: `Pressed ${key} key`,
333
+ },
334
+ ],
335
+ };
336
+ }
337
+ );
338
+
339
+ // =====================================================
340
+ // TOOL: launch_app
341
+ // =====================================================
342
+ server.tool(
343
+ "launch_app",
344
+ "Launch an application by its package name",
345
+ {
346
+ package: z.string().describe("Package name of the app (e.g., com.android.chrome)"),
347
+ },
348
+ async ({ package: pkg }) => {
349
+ await shell(`monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`);
350
+
351
+ return {
352
+ content: [
353
+ {
354
+ type: "text",
355
+ text: `Launched ${pkg}`,
356
+ },
357
+ ],
358
+ };
359
+ }
360
+ );
361
+
362
+ // =====================================================
363
+ // TOOL: install_apk
364
+ // =====================================================
365
+ server.tool(
366
+ "install_apk",
367
+ "Install an APK file on the device",
368
+ {
369
+ path: z.string().describe("Path to the APK file"),
370
+ },
371
+ async ({ path: apkPath }) => {
372
+ const result = await adb(`install -r ${apkPath}`);
373
+
374
+ return {
375
+ content: [
376
+ {
377
+ type: "text",
378
+ text: `APK installed: ${result}`,
379
+ },
380
+ ],
381
+ };
382
+ }
383
+ );
384
+
385
+ // =====================================================
386
+ // TOOL: list_packages
387
+ // =====================================================
388
+ server.tool(
389
+ "list_packages",
390
+ "List installed packages on the device",
391
+ {
392
+ filter: z.string().optional().describe("Filter packages by name (optional)"),
393
+ },
394
+ async ({ filter }) => {
395
+ let cmd = "pm list packages";
396
+ if (filter) {
397
+ cmd += ` | grep -i "${filter}"`;
398
+ }
399
+
400
+ const result = await shell(cmd);
401
+ const packages = result.split("\n").map((p) => p.replace("package:", "")).filter(Boolean);
402
+
403
+ return {
404
+ content: [
405
+ {
406
+ type: "text",
407
+ text: `Installed packages:\n${packages.join("\n")}`,
408
+ },
409
+ ],
410
+ };
411
+ }
412
+ );
413
+
414
+ // =====================================================
415
+ // TOOL: get_logs
416
+ // =====================================================
417
+ server.tool(
418
+ "get_logs",
419
+ "Get device logs (logcat)",
420
+ {
421
+ filter: z.string().optional().describe("Filter logs by tag or keyword"),
422
+ lines: z.number().optional().describe("Number of lines to retrieve (default: 50)"),
423
+ level: z.enum(["V", "D", "I", "W", "E"]).optional().describe("Minimum log level (V=Verbose, D=Debug, I=Info, W=Warn, E=Error)"),
424
+ },
425
+ async ({ filter, lines = 50, level }) => {
426
+ let cmd = `logcat -d -t ${lines}`;
427
+ if (level) {
428
+ cmd += ` *:${level}`;
429
+ }
430
+ if (filter) {
431
+ cmd += ` | grep -i "${filter}"`;
432
+ }
433
+
434
+ const logs = await shell(cmd);
435
+
436
+ return {
437
+ content: [
438
+ {
439
+ type: "text",
440
+ text: `Logs:\n${logs}`,
441
+ },
442
+ ],
443
+ };
444
+ }
445
+ );
446
+
447
+ // =====================================================
448
+ // TOOL: device_info
449
+ // =====================================================
450
+ server.tool(
451
+ "device_info",
452
+ "Get information about the connected device",
453
+ {},
454
+ async () => {
455
+ const [model, android, sdk, density, size, battery] = await Promise.all([
456
+ shell("getprop ro.product.model"),
457
+ shell("getprop ro.build.version.release"),
458
+ shell("getprop ro.build.version.sdk"),
459
+ shell("wm density"),
460
+ shell("wm size"),
461
+ shell("dumpsys battery | grep level"),
462
+ ]);
463
+
464
+ return {
465
+ content: [
466
+ {
467
+ type: "text",
468
+ text: `Device: ${model}
469
+ Android: ${android} (SDK ${sdk})
470
+ Screen: ${size.replace("Physical size: ", "")}
471
+ Density: ${density.replace("Physical density: ", "")}
472
+ Battery: ${battery.replace("level: ", "")}%`,
473
+ },
474
+ ],
475
+ };
476
+ }
477
+ );
478
+
479
+ // =====================================================
480
+ // TOOL: clear_app_data
481
+ // =====================================================
482
+ server.tool(
483
+ "clear_app_data",
484
+ "Clear all data for an application",
485
+ {
486
+ package: z.string().describe("Package name of the app"),
487
+ },
488
+ async ({ package: pkg }) => {
489
+ await shell(`pm clear ${pkg}`);
490
+
491
+ return {
492
+ content: [
493
+ {
494
+ type: "text",
495
+ text: `Data cleared for ${pkg}`,
496
+ },
497
+ ],
498
+ };
499
+ }
500
+ );
501
+
502
+ // =====================================================
503
+ // TOOL: force_stop
504
+ // =====================================================
505
+ server.tool(
506
+ "force_stop",
507
+ "Force stop an application",
508
+ {
509
+ package: z.string().describe("Package name of the app"),
510
+ },
511
+ async ({ package: pkg }) => {
512
+ await shell(`am force-stop ${pkg}`);
513
+
514
+ return {
515
+ content: [
516
+ {
517
+ type: "text",
518
+ text: `Force stopped ${pkg}`,
519
+ },
520
+ ],
521
+ };
522
+ }
523
+ );
524
+
525
+ // =====================================================
526
+ // TOOL: get_current_activity
527
+ // =====================================================
528
+ server.tool(
529
+ "get_current_activity",
530
+ "Get the currently focused activity/screen",
531
+ {},
532
+ async () => {
533
+ const result = await shell("dumpsys activity activities | grep mResumedActivity");
534
+
535
+ return {
536
+ content: [
537
+ {
538
+ type: "text",
539
+ text: `Current activity: ${result.trim()}`,
540
+ },
541
+ ],
542
+ };
543
+ }
544
+ );
545
+
546
+ // =====================================================
547
+ // TOOL: wait_for_element
548
+ // =====================================================
549
+ server.tool(
550
+ "wait_for_element",
551
+ "Wait for a UI element with specific text to appear",
552
+ {
553
+ text: z.string().describe("Text of the element to wait for"),
554
+ timeout: z.number().optional().describe("Timeout in seconds (default: 10)"),
555
+ },
556
+ async ({ text, timeout = 10 }) => {
557
+ const startTime = Date.now();
558
+ const timeoutMs = timeout * 1000;
559
+
560
+ while (Date.now() - startTime < timeoutMs) {
561
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
562
+ const xml = await shell("cat /sdcard/ui_dump.xml");
563
+
564
+ if (xml.toLowerCase().includes(text.toLowerCase())) {
565
+ return {
566
+ content: [
567
+ {
568
+ type: "text",
569
+ text: `Element "${text}" found after ${Math.round((Date.now() - startTime) / 1000)}s`,
570
+ },
571
+ ],
572
+ };
573
+ }
574
+
575
+ // Wait 500ms before next check
576
+ await new Promise((resolve) => setTimeout(resolve, 500));
577
+ }
578
+
579
+ return {
580
+ content: [
581
+ {
582
+ type: "text",
583
+ text: `Timeout: Element "${text}" not found after ${timeout}s`,
584
+ },
585
+ ],
586
+ };
587
+ }
588
+ );
589
+
590
+ // Start server
591
+ async function main() {
592
+ const transport = new StdioServerTransport();
593
+ await server.connect(transport);
594
+ console.error("MCP Android Emulator Server running on stdio");
595
+ }
596
+
597
+ main().catch(console.error);
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }