mobile-debug-mcp 0.3.0 → 0.5.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.
@@ -0,0 +1,33 @@
1
+ # Copilot Instructions for mobile-debug-mcp
2
+
3
+ ## Build and Run
4
+ - **Build**: `npm run build` (runs `tsc` to compile TypeScript to `dist/`)
5
+ - **Start**: `npm start` (runs the compiled server at `dist/server.js`)
6
+ - **Dev Workflow**: modify `src/*.ts` -> `npm run build` -> `npm start` to test changes.
7
+
8
+ ## Architecture
9
+ - **Core**: `src/server.ts` implements the MCP server using `@modelcontextprotocol/sdk`. It handles tool registration and execution.
10
+ - **Platform Modules**:
11
+ - `src/android.ts`: Encapsulates `adb` commands for Android device interaction.
12
+ - `src/ios.ts`: Encapsulates `xcrun simctl` commands for iOS simulator interaction.
13
+ - **Types**: `src/types.ts` defines shared interfaces for device info and tool responses.
14
+
15
+ ## Key Conventions
16
+
17
+ ### Tool Implementation
18
+ - **Response Format**: Tools typically return a list of content blocks.
19
+ - `start_app`: Returns a single text block containing JSON (via `wrapResponse`).
20
+ - `terminate_app`: Returns a single text block containing JSON (via `wrapResponse`).
21
+ - `restart_app`: Returns a single text block containing JSON (via `wrapResponse`).
22
+ - `reset_app_data`: Returns a single text block containing JSON (via `wrapResponse`).
23
+ - `get_logs`: Returns a text block (JSON metadata) AND a text block (raw logs).
24
+ - `capture_screenshot`: Returns a text block (JSON metadata) AND an image block (base64 PNG).
25
+ - **Metadata**: Always include a `device` object (platform, id, model, etc.) in the JSON response part.
26
+
27
+ ### External Tools
28
+ - **Android**: Uses `process.env.ADB_PATH` or defaults to `adb`.
29
+ - **iOS**: Uses `process.env.XCRUN_PATH` or defaults to `xcrun`. Assumes a booted simulator.
30
+ - **Execution**: Uses `child_process.exec` for running shell commands.
31
+
32
+ ### Error Handling
33
+ - Tools should catch execution errors and return a user-friendly error message in a `text` content block, rather than crashing the server.
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Mobile Debug MCP
2
2
 
3
- **Mobile Debug MCP** is a minimal MCP server for AI-assisted mobile development. It allows you to **launch Android or iOS apps** and **read their logs** from an MCP-compatible AI client.
3
+ **Mobile Debug MCP** is a minimal, secure MCP server for AI-assisted mobile development. It allows you to **launch Android or iOS apps**, **read their logs**, and **inspect UI** from an MCP-compatible AI client.
4
+
5
+ This server is designed with security in mind, using strict argument handling to prevent shell injection, and reliability, with robust process management to avoid hanging operations.
6
+
7
+ > **Note:** iOS support is currently an untested Work In Progress (WIP). Please use with caution and report any issues.
4
8
 
5
9
  ---
6
10
 
@@ -9,6 +13,9 @@
9
13
  - Launch Android apps via package name.
10
14
  - Launch iOS apps via bundle ID on a booted simulator.
11
15
  - Fetch recent logs from Android or iOS apps.
16
+ - Terminate and restart apps.
17
+ - Clear app data for fresh installs.
18
+ - Capture screenshots.
12
19
  - Cross-platform support (Android + iOS).
13
20
  - Minimal, focused design for fast debugging loops.
14
21
 
@@ -48,9 +55,29 @@ This option installs the package globally for easy use without cloning the repo.
48
55
 
49
56
  ---
50
57
 
51
- ## MCP Server Configuration
52
-
53
- Example WebUI MCP config using `npx --yes` and `ADB_PATH` environment variable:
58
+ ## Testing
59
+ 33.
60
+ 34. The repository includes a smoke test script to verify end-to-end functionality on real devices or simulators.
61
+ 35.
62
+ 36. ```bash
63
+ 37. # Run smoke test for Android
64
+ 38. npx tsx smoke-test.ts android com.example.package
65
+ 39.
66
+ 40. # Run smoke test for iOS
67
+ 41. npx tsx smoke-test.ts ios com.example.bundleid
68
+ 42. ```
69
+ 43.
70
+ 44. The smoke test performs the following sequence:
71
+ 45. 1. Starts the app
72
+ 46. 2. Captures a screenshot
73
+ 47. 3. Fetches logs
74
+ 48. 4. Terminates the app
75
+ 49.
76
+ 50. ---
77
+ 51.
78
+ 52. ## MCP Server Configuration
79
+
80
+ Example WebUI MCP config using `npx --yes` and environment variables:
54
81
 
55
82
  ```json
56
83
  {
@@ -60,62 +87,150 @@ Example WebUI MCP config using `npx --yes` and `ADB_PATH` environment variable:
60
87
  "args": [
61
88
  "--yes",
62
89
  "mobile-debug-mcp",
63
- "server",
64
- "--adb-path",
65
- "${ADB_PATH}"
66
- ]
90
+ "server"
91
+ ],
92
+ "env": {
93
+ "ADB_PATH": "/path/to/adb",
94
+ "XCRUN_PATH": "/usr/bin/xcrun"
95
+ }
67
96
  }
68
97
  }
69
98
  }
70
99
  ```
71
100
 
72
- > Make sure to set the `ADB_PATH` environment variable to the full path of your `adb` executable.
101
+ > Make sure to set `ADB_PATH` (Android) and `XCRUN_PATH` (iOS) if the tools are not in your system PATH.
73
102
 
74
103
  ---
75
104
 
76
105
  ## Tools
77
106
 
107
+ All tools accept a JSON input payload and return a structured JSON response. **Every response includes a `device` object** (with information about the selected device/simulator used for the operation), plus the tool-specific output.
108
+
78
109
  ### start_app
79
110
  Launch a mobile app.
80
111
 
81
112
  **Input:**
113
+ ```json
114
+ {
115
+ "platform": "android" | "ios",
116
+ "appId": "com.example.app", // Android package or iOS bundle ID (Required)
117
+ "deviceId": "emulator-5554" // Optional: target specific device/simulator
118
+ }
119
+ ```
82
120
 
121
+ **Response:**
122
+ ```json
123
+ {
124
+ "device": { /* device info */ },
125
+ "appStarted": true,
126
+ "launchTimeMs": 123
127
+ }
128
+ ```
129
+
130
+ ### get_logs
131
+ Fetch recent logs from the app or device.
132
+
133
+ **Input:**
83
134
  ```json
84
135
  {
85
136
  "platform": "android" | "ios",
86
- "id": "com.example.app" // Android package or iOS bundle ID
137
+ "appId": "com.example.app", // Optional: filter logs by app
138
+ "deviceId": "emulator-5554", // Optional: target specific device
139
+ "lines": 200 // Optional: number of lines (Android only)
87
140
  }
88
141
  ```
89
142
 
90
- **Example:**
143
+ **Response:**
144
+ Returns two content blocks:
145
+ 1. JSON metadata:
146
+ ```json
147
+ {
148
+ "device": { /* device info */ },
149
+ "result": { "lines": 50, "crashLines": [...] }
150
+ }
151
+ ```
152
+ 2. Plain text log output.
91
153
 
154
+ ### capture_screenshot
155
+ Capture a screenshot of the current device screen.
156
+
157
+ **Input:**
92
158
  ```json
93
159
  {
94
- "platform": "android",
95
- "id": "com.example.app"
160
+ "platform": "android" | "ios",
161
+ "deviceId": "emulator-5554" // Optional: target specific device
96
162
  }
97
163
  ```
98
164
 
99
- ### get_logs
100
- Fetch recent logs from the app.
165
+ **Response:**
166
+ Returns two content blocks:
167
+ 1. JSON metadata:
168
+ ```json
169
+ {
170
+ "device": { /* device info */ },
171
+ "result": { "resolution": { "width": 1080, "height": 1920 } }
172
+ }
173
+ ```
174
+ 2. Image content (image/png) containing the raw PNG data.
175
+
176
+ ### terminate_app
177
+ Terminate a running app.
101
178
 
102
179
  **Input:**
180
+ ```json
181
+ {
182
+ "platform": "android" | "ios",
183
+ "appId": "com.example.app", // Android package or iOS bundle ID (Required)
184
+ "deviceId": "emulator-5554" // Optional
185
+ }
186
+ ```
103
187
 
188
+ **Response:**
189
+ ```json
190
+ {
191
+ "device": { /* device info */ },
192
+ "appTerminated": true
193
+ }
194
+ ```
195
+
196
+ ### restart_app
197
+ Restart an app (terminate then launch).
198
+
199
+ **Input:**
104
200
  ```json
105
201
  {
106
202
  "platform": "android" | "ios",
107
- "id": "com.example.app", // Android package or iOS bundle ID (required)
108
- "lines": 200 // optional, Android only
203
+ "appId": "com.example.app", // Android package or iOS bundle ID (Required)
204
+ "deviceId": "emulator-5554" // Optional
109
205
  }
110
206
  ```
111
207
 
112
- **Example:**
208
+ **Response:**
209
+ ```json
210
+ {
211
+ "device": { /* device info */ },
212
+ "appRestarted": true,
213
+ "launchTimeMs": 123
214
+ }
215
+ ```
216
+
217
+ ### reset_app_data
218
+ Clear app storage (reset to fresh install state).
219
+
220
+ **Input:**
221
+ ```json
222
+ {
223
+ "platform": "android" | "ios",
224
+ "appId": "com.example.app", // Android package or iOS bundle ID (Required)
225
+ "deviceId": "emulator-5554" // Optional
226
+ }
227
+ ```
113
228
 
229
+ **Response:**
114
230
  ```json
115
231
  {
116
- "platform": "android",
117
- "id": "com.example.app",
118
- "lines": 200
232
+ "device": { /* device info */ },
233
+ "dataCleared": true
119
234
  }
120
235
  ```
121
236
 
@@ -126,15 +241,17 @@ Fetch recent logs from the app.
126
241
  1. Ensure Android device or iOS simulator is running.
127
242
  2. Use `start_app` to launch the app.
128
243
  3. Use `get_logs` to read the latest logs.
129
- 4. Repeat for debugging loops.
244
+ 4. Use `capture_screenshot` to visually inspect the app if needed.
245
+ 5. Use `reset_app_data` to clear state if debugging fresh install scenarios.
246
+ 6. Use `restart_app` to quickly reboot the app during development cycles.
130
247
 
131
248
  ---
132
249
 
133
250
  ## Notes
134
251
 
135
- - Ensure `adb` and `xcrun` are in your PATH or set `ADB_PATH` accordingly.
136
- - For iOS, the simulator must be booted before using `start_app` or `get_logs`.
137
- - You may want to clear Android logs before launching for cleaner output: `adb logcat -c`
252
+ - Ensure `adb` and `xcrun` are in your PATH or set `ADB_PATH` / `XCRUN_PATH` accordingly.
253
+ - For iOS, the simulator must be booted before using tools.
254
+ - The `capture_screenshot` tool returns a multi-block response: a JSON text block with metadata, followed by an image block containing the base64-encoded PNG data.
138
255
 
139
256
  ---
140
257
 
package/dist/android.js CHANGED
@@ -1,44 +1,186 @@
1
- import { exec } from "child_process";
1
+ import { spawn } from "child_process";
2
2
  const ADB = process.env.ADB_PATH || "adb";
3
- export function startAndroidApp(pkg) {
4
- return new Promise((resolve, reject) => {
5
- exec(`${ADB} shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, (err, stdout, stderr) => {
6
- if (err)
7
- reject(stderr);
8
- else
9
- resolve(stdout);
10
- });
11
- });
3
+ // Helper to construct ADB args with optional device ID
4
+ function getAdbArgs(args, deviceId) {
5
+ if (deviceId) {
6
+ return ['-s', deviceId, ...args];
7
+ }
8
+ return args;
12
9
  }
13
- export function getAndroidLogs(pkg, lines = 200) {
10
+ function execAdb(args, deviceId, options = {}) {
11
+ const adbArgs = getAdbArgs(args, deviceId);
14
12
  return new Promise((resolve, reject) => {
15
- exec(`${ADB} shell pidof -s ${pkg}`, (pidErr, pidStdout, pidStderr) => {
16
- if (pidErr || !pidStdout.trim()) {
17
- reject(pidStderr || "App process not running");
18
- return;
19
- }
20
- const pid = pidStdout.trim();
21
- exec(`${ADB} logcat -d --pid=${pid} -t ${lines} -v threadtime`, (err, stdout, stderr) => {
22
- if (err)
23
- reject(stderr || err.message);
24
- else
25
- resolve(stdout);
13
+ // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
14
+ const child = spawn(ADB, adbArgs, options);
15
+ let stdout = '';
16
+ let stderr = '';
17
+ if (child.stdout) {
18
+ child.stdout.on('data', (data) => {
19
+ stdout += data.toString();
20
+ });
21
+ }
22
+ if (child.stderr) {
23
+ child.stderr.on('data', (data) => {
24
+ stderr += data.toString();
26
25
  });
26
+ }
27
+ const timeoutMs = args.includes('logcat') ? 10000 : 2000; // Shorter timeout for metadata queries
28
+ const timeout = setTimeout(() => {
29
+ child.kill();
30
+ reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
31
+ }, timeoutMs);
32
+ child.on('close', (code) => {
33
+ clearTimeout(timeout);
34
+ if (code !== 0) {
35
+ // If there's an actual error (non-zero exit code), reject
36
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
37
+ }
38
+ else {
39
+ // If exit code is 0, resolve with stdout
40
+ resolve(stdout.trim());
41
+ }
42
+ });
43
+ child.on('error', (err) => {
44
+ clearTimeout(timeout);
45
+ reject(err);
27
46
  });
28
47
  });
29
48
  }
30
- export async function getAndroidCrash(pkg, lines = 200) {
49
+ function getDeviceInfo(deviceId, metadata = {}) {
50
+ return {
51
+ platform: 'android',
52
+ id: deviceId || 'default',
53
+ osVersion: metadata.osVersion || '',
54
+ model: metadata.model || '',
55
+ simulator: metadata.simulator || false
56
+ };
57
+ }
58
+ export async function getAndroidDeviceMetadata(appId, deviceId) {
31
59
  try {
32
- const logs = await getAndroidLogs(pkg, lines);
33
- const crashLines = logs
34
- .split('\n')
35
- .filter(line => line.includes('FATAL EXCEPTION'));
36
- if (crashLines.length === 0) {
37
- return "No crashes found.";
60
+ // Run these in parallel to avoid sequential timeouts
61
+ const [osVersion, model, simOutput] = await Promise.all([
62
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], deviceId).catch(() => ''),
63
+ execAdb(['shell', 'getprop', 'ro.product.model'], deviceId).catch(() => ''),
64
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], deviceId).catch(() => '0')
65
+ ]);
66
+ const simulator = simOutput === '1';
67
+ return { platform: 'android', id: deviceId || 'default', osVersion, model, simulator };
68
+ }
69
+ catch (e) {
70
+ return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
71
+ }
72
+ }
73
+ export async function startAndroidApp(appId, deviceId) {
74
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
75
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
76
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
77
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
78
+ }
79
+ export async function terminateAndroidApp(appId, deviceId) {
80
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
81
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
82
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
83
+ return { device: deviceInfo, appTerminated: true };
84
+ }
85
+ export async function restartAndroidApp(appId, deviceId) {
86
+ await terminateAndroidApp(appId, deviceId);
87
+ const startResult = await startAndroidApp(appId, deviceId);
88
+ return {
89
+ device: startResult.device,
90
+ appRestarted: startResult.appStarted,
91
+ launchTimeMs: startResult.launchTimeMs
92
+ };
93
+ }
94
+ export async function resetAndroidAppData(appId, deviceId) {
95
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
96
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
97
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
98
+ return { device: deviceInfo, dataCleared: output === 'Success' };
99
+ }
100
+ export async function getAndroidLogs(appId, lines = 200, deviceId) {
101
+ const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
102
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
103
+ try {
104
+ // We'll skip PID lookup for now to avoid potential hangs with 'pidof' on some emulators
105
+ // and rely on robust string matching against the log line.
106
+ // Get logs
107
+ const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId);
108
+ const allLogs = stdout.split('\n');
109
+ let filteredLogs = allLogs;
110
+ if (appId) {
111
+ // Filter by checking if the line contains the appId string.
112
+ const matchingLogs = allLogs.filter(line => line.includes(appId));
113
+ if (matchingLogs.length > 0) {
114
+ filteredLogs = matchingLogs;
115
+ }
116
+ else {
117
+ // Fallback: if no logs match the appId, return the raw logs (last N lines)
118
+ // This matches the behavior of the "working" version provided by the user,
119
+ // ensuring they at least see system activity if the app is silent or crashing early.
120
+ filteredLogs = allLogs;
121
+ }
38
122
  }
39
- return crashLines.join('\n');
123
+ return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length };
40
124
  }
41
- catch (error) {
42
- return `Error retrieving crash logs: ${error}`;
125
+ catch (e) {
126
+ console.error("Error fetching logs:", e);
127
+ return { device: deviceInfo, logs: [], logCount: 0 };
43
128
  }
44
129
  }
130
+ export async function captureAndroidScreen(deviceId) {
131
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
132
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
133
+ return new Promise((resolve, reject) => {
134
+ const adbArgs = getAdbArgs(['exec-out', 'screencap', '-p'], deviceId);
135
+ // Using spawn for screencap as well to ensure consistent process handling
136
+ const child = spawn(ADB, adbArgs);
137
+ const chunks = [];
138
+ let stderr = '';
139
+ child.stdout.on('data', (chunk) => {
140
+ chunks.push(Buffer.from(chunk));
141
+ });
142
+ child.stderr.on('data', (data) => {
143
+ stderr += data.toString();
144
+ });
145
+ const timeout = setTimeout(() => {
146
+ child.kill();
147
+ reject(new Error(`ADB screencap timed out after 10s`));
148
+ }, 10000);
149
+ child.on('close', (code) => {
150
+ clearTimeout(timeout);
151
+ if (code !== 0) {
152
+ reject(new Error(stderr.trim() || `Screencap failed with code ${code}`));
153
+ return;
154
+ }
155
+ const screenshotBuffer = Buffer.concat(chunks);
156
+ const screenshotBase64 = screenshotBuffer.toString('base64');
157
+ // Get resolution
158
+ execAdb(['shell', 'wm', 'size'], deviceId)
159
+ .then(sizeStdout => {
160
+ let width = 0;
161
+ let height = 0;
162
+ const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
163
+ if (match) {
164
+ width = parseInt(match[1], 10);
165
+ height = parseInt(match[2], 10);
166
+ }
167
+ resolve({
168
+ device: deviceInfo,
169
+ screenshot: screenshotBase64,
170
+ resolution: { width, height }
171
+ });
172
+ })
173
+ .catch(() => {
174
+ resolve({
175
+ device: deviceInfo,
176
+ screenshot: screenshotBase64,
177
+ resolution: { width: 0, height: 0 }
178
+ });
179
+ });
180
+ });
181
+ child.on('error', (err) => {
182
+ clearTimeout(timeout);
183
+ reject(err);
184
+ });
185
+ });
186
+ }