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 +21 -0
- package/README.md +226 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +451 -0
- package/package.json +48 -0
- package/src/index.ts +597 -0
- package/tsconfig.json +16 -0
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)
|
package/dist/index.d.ts
ADDED
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
|
+
}
|