mobile-debug-mcp 0.7.0 → 0.9.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.
Files changed (51) hide show
  1. package/README.md +18 -443
  2. package/dist/android/interact.js +96 -1
  3. package/dist/android/utils.js +404 -12
  4. package/dist/ios/interact.js +105 -0
  5. package/dist/ios/utils.js +154 -0
  6. package/dist/resolve-device.js +70 -0
  7. package/dist/server.js +126 -194
  8. package/dist/tools/app.js +45 -0
  9. package/dist/tools/devices.js +5 -0
  10. package/dist/tools/install.js +47 -0
  11. package/dist/tools/logs.js +62 -0
  12. package/dist/tools/screenshot.js +17 -0
  13. package/dist/tools/ui.js +57 -0
  14. package/docs/CHANGELOG.md +19 -0
  15. package/docs/TOOLS.md +272 -0
  16. package/package.json +6 -2
  17. package/src/android/interact.ts +100 -1
  18. package/src/android/utils.ts +395 -10
  19. package/src/ios/interact.ts +102 -0
  20. package/src/ios/utils.ts +157 -0
  21. package/src/resolve-device.ts +80 -0
  22. package/src/server.ts +149 -276
  23. package/src/tools/app.ts +46 -0
  24. package/src/tools/devices.ts +6 -0
  25. package/src/tools/install.ts +43 -0
  26. package/src/tools/logs.ts +62 -0
  27. package/src/tools/screenshot.ts +18 -0
  28. package/src/tools/ui.ts +62 -0
  29. package/src/types.ts +7 -0
  30. package/test/integration/index.ts +8 -0
  31. package/test/integration/install.integration.ts +64 -0
  32. package/test/integration/logstream-real.ts +35 -0
  33. package/test/integration/run-install-android.ts +21 -0
  34. package/test/integration/run-install-ios.ts +21 -0
  35. package/test/integration/run-real-test.ts +19 -0
  36. package/{smoke-test.ts → test/integration/smoke-test.ts} +17 -25
  37. package/test/integration/test-dist.mjs +41 -0
  38. package/test/integration/test-dist.ts +41 -0
  39. package/{test-ui-tree.ts → test/integration/test-ui-tree.ts} +2 -2
  40. package/test/integration/wait_for_element_real.ts +80 -0
  41. package/test/unit/index.ts +7 -0
  42. package/test/unit/install.test.ts +82 -0
  43. package/test/unit/logparse.test.ts +41 -0
  44. package/test/unit/logstream.test.ts +46 -0
  45. package/test/unit/wait_for_element_mock.ts +104 -0
  46. package/tsconfig.json +1 -1
  47. package/smoke-test.js +0 -102
  48. package/test/run-real-test.js +0 -24
  49. package/test/wait_for_element_mock.js +0 -113
  50. package/test/wait_for_element_real.js +0 -67
  51. package/test-ui-tree.js +0 -68
@@ -0,0 +1,5 @@
1
+ import { listDevices } from '../resolve-device.js';
2
+ export async function listDevicesHandler({ platform, appId }) {
3
+ const devices = await listDevices(platform, appId);
4
+ return { devices };
5
+ }
@@ -0,0 +1,47 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { resolveTargetDevice } from '../resolve-device.js';
4
+ import { AndroidInteract } from '../android/interact.js';
5
+ import { iOSInteract } from '../ios/interact.js';
6
+ const androidInteract = new AndroidInteract();
7
+ const iosInteract = new iOSInteract();
8
+ export async function installAppHandler({ platform, appPath, deviceId }) {
9
+ let chosenPlatform = platform;
10
+ try {
11
+ const stat = await fs.stat(appPath).catch(() => null);
12
+ if (stat && stat.isDirectory()) {
13
+ const files = (await fs.readdir(appPath).catch(() => []));
14
+ if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
15
+ chosenPlatform = 'ios';
16
+ }
17
+ else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
18
+ chosenPlatform = 'android';
19
+ }
20
+ else {
21
+ chosenPlatform = 'android';
22
+ }
23
+ }
24
+ else if (typeof appPath === 'string') {
25
+ const ext = path.extname(appPath).toLowerCase();
26
+ if (ext === '.apk')
27
+ chosenPlatform = 'android';
28
+ else if (ext === '.ipa' || ext === '.app')
29
+ chosenPlatform = 'ios';
30
+ else
31
+ chosenPlatform = 'android';
32
+ }
33
+ }
34
+ catch (e) {
35
+ chosenPlatform = 'android';
36
+ }
37
+ if (chosenPlatform === 'android') {
38
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
39
+ const result = await androidInteract.installApp(appPath, resolved.id);
40
+ return result;
41
+ }
42
+ else {
43
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
44
+ const result = await iosInteract.installApp(appPath, resolved.id);
45
+ return result;
46
+ }
47
+ }
@@ -0,0 +1,62 @@
1
+ import { resolveTargetDevice } from '../resolve-device.js';
2
+ import { AndroidObserve } from '../android/observe.js';
3
+ import { iOSObserve } from '../ios/observe.js';
4
+ import { startAndroidLogStream, readLogStreamLines, stopAndroidLogStream } from '../android/utils.js';
5
+ import { startIOSLogStream, readIOSLogStreamLines, stopIOSLogStream } from '../ios/utils.js';
6
+ const androidObserve = new AndroidObserve();
7
+ export async function getLogsHandler({ platform, appId, deviceId, lines }) {
8
+ if (platform === 'android') {
9
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
10
+ const deviceInfo = resolved;
11
+ const response = await androidObserve.getLogs(appId, lines ?? 200, resolved.id);
12
+ const logs = Array.isArray(response.logs) ? response.logs : [];
13
+ const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'));
14
+ return { device: deviceInfo, logs, crashLines };
15
+ }
16
+ else {
17
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
18
+ const deviceInfo = resolved;
19
+ try {
20
+ const iosObs = new iOSObserve();
21
+ const resp = await iosObs.getLogs(appId, resolved.id);
22
+ const logs = Array.isArray(resp.logs) ? resp.logs : [];
23
+ const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
24
+ return { device: deviceInfo, logs, crashLines };
25
+ }
26
+ catch (e) {
27
+ return { device: deviceInfo, logs: [], crashLines: [] };
28
+ }
29
+ }
30
+ }
31
+ export async function startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
32
+ const effectivePlatform = platform || 'android';
33
+ const sid = sessionId || 'default';
34
+ if (effectivePlatform === 'android') {
35
+ const resolved = await resolveTargetDevice({ platform: 'android', appId: packageName, deviceId });
36
+ return await startAndroidLogStream(packageName, level || 'error', resolved.id, sid);
37
+ }
38
+ else {
39
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId: packageName, deviceId });
40
+ return await startIOSLogStream(packageName, level || 'error', resolved.id, sid);
41
+ }
42
+ }
43
+ export async function readLogStreamHandler({ platform, sessionId, limit, since }) {
44
+ const effectivePlatform = platform || 'android';
45
+ const sid = sessionId || 'default';
46
+ if (effectivePlatform === 'android') {
47
+ return await readLogStreamLines(sid, limit ?? 100, since);
48
+ }
49
+ else {
50
+ return await readIOSLogStreamLines(sid, limit ?? 100, since);
51
+ }
52
+ }
53
+ export async function stopLogStreamHandler({ platform, sessionId }) {
54
+ const effectivePlatform = platform || 'android';
55
+ const sid = sessionId || 'default';
56
+ if (effectivePlatform === 'android') {
57
+ return await stopAndroidLogStream(sid);
58
+ }
59
+ else {
60
+ return await stopIOSLogStream(sid);
61
+ }
62
+ }
@@ -0,0 +1,17 @@
1
+ import { resolveTargetDevice } from '../resolve-device.js';
2
+ import { AndroidObserve } from '../android/observe.js';
3
+ import { iOSObserve } from '../ios/observe.js';
4
+ const androidObserve = new AndroidObserve();
5
+ const iosObserve = new iOSObserve();
6
+ export async function captureScreenshotHandler({ platform, deviceId }) {
7
+ if (platform === 'android') {
8
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
9
+ const result = await androidObserve.captureScreen(resolved.id);
10
+ return { device: resolved, resolution: result.resolution, screenshot: result.screenshot };
11
+ }
12
+ else {
13
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
14
+ const result = await iosObserve.captureScreenshot(resolved.id);
15
+ return { device: resolved, resolution: result.resolution, screenshot: result.screenshot };
16
+ }
17
+ }
@@ -0,0 +1,57 @@
1
+ import { resolveTargetDevice } from '../resolve-device.js';
2
+ import { AndroidObserve } from '../android/observe.js';
3
+ import { iOSObserve } from '../ios/observe.js';
4
+ import { AndroidInteract } from '../android/interact.js';
5
+ import { iOSInteract } from '../ios/interact.js';
6
+ const androidObserve = new AndroidObserve();
7
+ const iosObserve = new iOSObserve();
8
+ const androidInteract = new AndroidInteract();
9
+ const iosInteract = new iOSInteract();
10
+ export async function getUITreeHandler({ platform, deviceId }) {
11
+ if (platform === 'android') {
12
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
13
+ return await androidObserve.getUITree(resolved.id);
14
+ }
15
+ else {
16
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
17
+ return await iosObserve.getUITree(resolved.id);
18
+ }
19
+ }
20
+ export async function getCurrentScreenHandler({ deviceId }) {
21
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
22
+ return await androidObserve.getCurrentScreen(resolved.id);
23
+ }
24
+ export async function waitForElementHandler({ platform, text, timeout, deviceId }) {
25
+ const effectiveTimeout = timeout ?? 10000;
26
+ if (platform === 'android') {
27
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
28
+ return await androidInteract.waitForElement(text, effectiveTimeout, resolved.id);
29
+ }
30
+ else {
31
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
32
+ return await iosInteract.waitForElement(text, effectiveTimeout, resolved.id);
33
+ }
34
+ }
35
+ export async function tapHandler({ platform, x, y, deviceId }) {
36
+ const effectivePlatform = platform || 'android';
37
+ if (effectivePlatform === 'android') {
38
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
39
+ return await androidInteract.tap(x, y, resolved.id);
40
+ }
41
+ else {
42
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
43
+ return await iosInteract.tap(x, y, resolved.id);
44
+ }
45
+ }
46
+ export async function swipeHandler({ x1, y1, x2, y2, duration, deviceId }) {
47
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
48
+ return await androidInteract.swipe(x1, y1, x2, y2, duration, resolved.id);
49
+ }
50
+ export async function typeTextHandler({ text, deviceId }) {
51
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
52
+ return await androidInteract.typeText(text, resolved.id);
53
+ }
54
+ export async function pressBackHandler({ deviceId }) {
55
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
56
+ return await androidInteract.pressBack(resolved.id);
57
+ }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.9.0] - 2026-03-14
6
+
7
+ ### Added / Changed
8
+ - install_app now builds apps when given a project directory and then installs the produced artifact (Android: Gradle wrapper assembleDebug; iOS: xcodebuild where applicable).
9
+ - Auto-detects and prefers JDK 17 (Android Studio JBR or system JDK). Any JAVA_HOME overrides are scoped to the spawned build process, avoiding global system changes.
10
+ - Respects ADB_PATH and falls back to PATH if unset. Set ADB_PATH to an explicit adb binary to avoid PATH discovery issues.
11
+ - Increased default adb timeout to 120s for installs; timeout can be configured via MCP_ADB_TIMEOUT or ADB_TIMEOUT environment variables.
12
+ - Improved logging and error messages for build/install steps. Unit and integration tests updated and converted to TypeScript.
13
+
14
+ ## [0.8.0]
15
+
16
+ ### Added
17
+ - **`list_devices` tool**: enumerate connected Android devices and iOS simulators. Returns device metadata (id, platform, osVersion, model, simulator, appInstalled).
18
+ - **`install_app` tool**: install an APK (.apk) on Android or an app bundle (.app/.ipa) on iOS simulators/devices. Uses `adb install -r` for Android and `simctl`/`idb` for iOS.
19
+ - **`start_log_stream`, `read_log_stream`, `stop_log_stream` tools**: stream Android logcat filtered by application PID, poll parsed entries, support incremental reads (limit/since) and basic crash detection metadata (crash_detected, exception, sample).
20
+
21
+ ### Changed
22
+ - Device-selection: server handlers now use a central resolver to pick a sensible default device when `deviceId` is omitted. This reduces duplication and makes behavior deterministic when multiple devices are attached.
23
+
5
24
  ## [0.7.0]
6
25
 
7
26
  ### Added
package/docs/TOOLS.md ADDED
@@ -0,0 +1,272 @@
1
+ # Tools
2
+
3
+ This document contains detailed definitions for each MCP tool implemented by Mobile Debug MCP. These were extracted from the README to keep the top-level README concise.
4
+
5
+ Each tool returns a JSON metadata block and, where applicable, additional content blocks (e.g., image data or raw logs). Where an example previously used `jsonc` fences with inline comments, the examples below use `json` fences and external explanatory notes to avoid highlighting issues.
6
+
7
+ ---
8
+
9
+ ## list_devices
10
+ Enumerate connected Android devices and iOS simulators.
11
+
12
+ Input (optional):
13
+ ```json
14
+ { "platform": "android" | "ios" }
15
+ ```
16
+
17
+ Response:
18
+ ```json
19
+ { "devices": [ { "id": "emulator-5554", "platform": "android", "osVersion": "11", "model": "sdk_gphone64_arm64", "simulator": true, "appInstalled": false } ] }
20
+ ```
21
+
22
+ Notes:
23
+ - Use `list_devices` to inspect connected targets and their metadata. When multiple devices are attached, pass `deviceId` to other tools to target a specific device.
24
+
25
+ ---
26
+
27
+ ## install_app
28
+ Install an app onto a connected device or simulator (APK for Android, .app/.ipa for iOS).
29
+
30
+ Input:
31
+ ```json
32
+ {
33
+ "platform": "android" | "ios",
34
+ "appPath": "/path/to/app.apk_or_app.app_or_ipa",
35
+ "deviceId": "emulator-5554"
36
+ }
37
+ ```
38
+
39
+ Response:
40
+ ```json
41
+ {
42
+ "device": { /* device info */ },
43
+ "installed": true,
44
+ "output": "Platform-specific installer output (adb/simctl/idb)",
45
+ "error": "Optional error message if installation failed"
46
+ }
47
+ ```
48
+
49
+ Notes:
50
+ - Android: the tool attempts to locate and install the APK. If `appPath` points at a project directory, the tool will attempt to run the Gradle wrapper (`./gradlew assembleDebug`) and locate the built APK under `build/outputs/apk/`.
51
+ - The installer respects `ADB_PATH` (preferred) falling back to `adb` on PATH. To avoid PATH discovery issues, set `ADB_PATH` to the full adb binary path.
52
+ - The default ADB command timeout was increased to 120s to handle larger streamed installs. Configure via `MCP_ADB_TIMEOUT` or `ADB_TIMEOUT` env vars.
53
+ - iOS: prefers `xcrun simctl install` for simulators and falls back to `idb install` for devices when available.
54
+
55
+ ---
56
+
57
+ ## start_app
58
+ Launch a mobile app.
59
+
60
+ Input:
61
+ ```json
62
+ {
63
+ "platform": "android" | "ios",
64
+ "appId": "com.example.app",
65
+ "deviceId": "emulator-5554"
66
+ }
67
+ ```
68
+
69
+ Response:
70
+ ```json
71
+ {
72
+ "device": { /* device info */ },
73
+ "appStarted": true,
74
+ "launchTimeMs": 123
75
+ }
76
+ ```
77
+
78
+ Notes:
79
+ - Android: uses `adb shell monkey -p <package> -c android.intent.category.LAUNCHER 1` to trigger a launch.
80
+ - iOS: uses `xcrun simctl launch` for simulators or `idb` for devices when available.
81
+
82
+ ---
83
+
84
+ ## get_logs
85
+ Fetch recent logs from the app or device.
86
+
87
+ Input:
88
+ ```json
89
+ {
90
+ "platform": "android" | "ios",
91
+ "appId": "com.example.app",
92
+ "deviceId": "emulator-5554",
93
+ "lines": 200
94
+ }
95
+ ```
96
+
97
+ Response:
98
+ - The tool returns two content blocks for Android: a JSON metadata block and a plain text log output block. The JSON metadata includes parsed results (counts, crash summaries) and the raw log is provided for inspection.
99
+
100
+ Notes:
101
+ - Android log parsing is heuristic and includes basic crash detection (searching for "FATAL EXCEPTION" and exception names).
102
+ - Use `lines` to control how many log lines are returned from `adb logcat`.
103
+
104
+ ---
105
+
106
+ ## capture_screenshot
107
+ Capture a screenshot of the current device screen.
108
+
109
+ Input:
110
+ ```json
111
+ {
112
+ "platform": "android" | "ios",
113
+ "deviceId": "emulator-5554"
114
+ }
115
+ ```
116
+
117
+ Response:
118
+ - JSON metadata block with resolution and device info, followed by an image/png block containing base64-encoded PNG bytes.
119
+
120
+ Notes:
121
+ - Android: uses `adb exec-out screencap -p` and returns PNG bytes.
122
+ - iOS: uses `xcrun simctl io booted screenshot` or `idb` capture when available.
123
+
124
+ ---
125
+
126
+ ## terminate_app
127
+ Terminate a running app.
128
+
129
+ Input:
130
+ ```json
131
+ {
132
+ "platform": "android" | "ios",
133
+ "appId": "com.example.app",
134
+ "deviceId": "emulator-5554"
135
+ }
136
+ ```
137
+
138
+ Response:
139
+ ```json
140
+ { "device": { /* device info */ }, "appTerminated": true }
141
+ ```
142
+
143
+ Notes:
144
+ - Android: uses `adb shell am force-stop <package>`.
145
+
146
+ ---
147
+
148
+ ## restart_app
149
+ Restart an app (terminate then launch).
150
+
151
+ Input/Response: combination of terminate + start as above. Response includes launch timing metadata.
152
+
153
+ ---
154
+
155
+ ## reset_app_data
156
+ Clear app storage (reset to fresh install state).
157
+
158
+ Input:
159
+ ```json
160
+ {
161
+ "platform": "android" | "ios",
162
+ "appId": "com.example.app",
163
+ "deviceId": "emulator-5554"
164
+ }
165
+ ```
166
+
167
+ Response:
168
+ ```json
169
+ { "device": { /* device info */ }, "dataCleared": true }
170
+ ```
171
+
172
+ Notes:
173
+ - Android: uses `adb shell pm clear <package>` and returns whether the operation succeeded.
174
+
175
+ ---
176
+
177
+ ## start_log_stream / read_log_stream / stop_log_stream
178
+ Start a live log stream (background) for an Android app and poll the accumulated entries.
179
+
180
+ - start_log_stream starts a background `adb logcat` filtered by the app PID and writes parsed NDJSON to a per-session file. Returns immediately with session details.
181
+ - read_log_stream retrieves recent parsed entries and includes crash detection metadata.
182
+ - stop_log_stream terminates the background process and closes the stream.
183
+
184
+ Input (start_log_stream):
185
+ ```json
186
+ { "packageName": "com.example.app", "level": "error" | "warn" | "info" | "debug", "sessionId": "optional-session-id" }
187
+ ```
188
+
189
+ Response (read_log_stream):
190
+ ```json
191
+ { "entries": [ /* parsed entries */ ], "crash_summary": { "crash_detected": true/false, "exception": "..." } }
192
+ ```
193
+
194
+ Notes:
195
+ - The `since` parameter for read_log_stream accepts ISO timestamps or epoch ms. Use it for incremental polling.
196
+ - Crash detection is heuristic-based and intended as a quick signal for agents.
197
+
198
+ ---
199
+
200
+ ## get_ui_tree
201
+ Get the current UI hierarchy from the device.
202
+
203
+ Input:
204
+ ```json
205
+ { "platform": "android" | "ios", "deviceId": "emulator-5554" }
206
+ ```
207
+
208
+ Response:
209
+ - Structured JSON containing screen metadata and an array of UI elements with properties: text, contentDescription, type, resourceId, clickable, enabled, visible, bounds, center, depth, parentId, children.
210
+
211
+ Notes:
212
+ - Android: uses `uiautomator dump` or `adb exec-out uiautomator` fallback methods. Times out on slow responses; use provided timeouts.
213
+ - iOS: uses `idb` or accessibility APIs when available.
214
+
215
+ ---
216
+
217
+ ## get_current_screen
218
+ Get the currently visible activity on Android.
219
+
220
+ Input:
221
+ ```json
222
+ { "deviceId": "emulator-5554" }
223
+ ```
224
+
225
+ Response:
226
+ ```json
227
+ { "device": { /* device info */ }, "package": "com.example.app", "activity": "com.example.app.LoginActivity", "shortActivity": "LoginActivity" }
228
+ ```
229
+
230
+ Notes:
231
+ - Uses `dumpsys activity activities` and robust parsing to support multiple Android versions.
232
+
233
+ ---
234
+
235
+ ## wait_for_element
236
+ Wait until a UI element with matching text appears on screen or timeout is reached.
237
+
238
+ Input:
239
+ ```json
240
+ { "platform": "android" | "ios", "text": "Home", "timeout": 5000, "deviceId": "emulator-5554" }
241
+ ```
242
+
243
+ Response:
244
+ ```json
245
+ { "device": { /* device info */ }, "found": true/false, "element": { /* element */ } }
246
+ ```
247
+
248
+ Notes:
249
+ - Polls get_ui_tree until timeout or element found. Returns an `error` field if system failures occur.
250
+
251
+ ---
252
+
253
+ ## tap / swipe / type_text / press_back
254
+
255
+ - tap: `adb shell input tap x y` (Android) or `idb` events for iOS.
256
+ - swipe: `adb shell input swipe x1 y1 x2 y2 duration`.
257
+ - type_text: `adb shell input text` (spaces encoded as %s) — may fail for special characters.
258
+ - press_back: `adb shell input keyevent 4`.
259
+
260
+ Inputs and responses follow the device+success pattern used across other tools.
261
+
262
+ ---
263
+
264
+ ## Notes on environment and timeout behavior
265
+
266
+ - The tools prefer explicit env vars: `ADB_PATH` and `XCRUN_PATH` to locate platform binaries. If unset, tools fall back to PATH lookup.
267
+ - For Android builds, the install tool auto-detects a suitable Java 17 installation (Android Studio JBR or system JDK 17). Any JAVA_HOME overrides are scoped to the spawned Gradle process.
268
+ - Default ADB timeout is now 120s for long operations; override via `MCP_ADB_TIMEOUT` or `ADB_TIMEOUT`.
269
+
270
+ ---
271
+
272
+ If you need the tools split into per-tool markdown files (e.g., docs/tools/install.md), say so and I will split them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,10 @@
9
9
  "scripts": {
10
10
  "build": "tsc",
11
11
  "start": "node ./dist/server.js",
12
- "prepare": "npm run build"
12
+ "prepare": "npm run build",
13
+ "test:unit": "tsx test/unit/index.ts",
14
+ "test:integration": "tsx test/integration/index.ts",
15
+ "test": "npm run test:unit && npm run test:integration"
13
16
  },
14
17
  "engines": {
15
18
  "node": ">=18"
@@ -21,6 +24,7 @@
21
24
  },
22
25
  "devDependencies": {
23
26
  "@types/node": "^25.4.0",
27
+ "tsx": "^4.21.0",
24
28
  "typescript": "^5.9.3"
25
29
  }
26
30
  }
@@ -1,6 +1,11 @@
1
1
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
- import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
2
+ import { execAdb, getAndroidDeviceMetadata, getDeviceInfo, detectJavaHome } from "./utils.js"
3
3
  import { AndroidObserve } from "./observe.js"
4
+ import { promises as fs } from "fs"
5
+ import { spawn, execSync } from "child_process"
6
+ import path from "path"
7
+ import { existsSync } from "fs"
8
+
4
9
 
5
10
  export class AndroidInteract {
6
11
  private observe = new AndroidObserve();
@@ -87,6 +92,100 @@ export class AndroidInteract {
87
92
  }
88
93
  }
89
94
 
95
+ async installApp(apkPath: string, deviceId?: string): Promise<import("../types.js").InstallAppResponse> {
96
+ const metadata = await getAndroidDeviceMetadata("", deviceId)
97
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
98
+
99
+ // Helper to recursively find first APK under a directory
100
+ async function findApk(dir: string): Promise<string | undefined> {
101
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
102
+ for (const e of entries) {
103
+ const full = path.join(dir, e.name)
104
+ if (e.isDirectory()) {
105
+ const found = await findApk(full)
106
+ if (found) return found
107
+ } else if (e.isFile() && full.endsWith('.apk')) {
108
+ return full
109
+ }
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ try {
115
+ let apkToInstall = apkPath
116
+
117
+ // If a directory is provided, attempt to build via Gradle
118
+ const stat = await fs.stat(apkPath).catch(() => null)
119
+ if (stat && stat.isDirectory()) {
120
+ const gradlewPath = path.join(apkPath, 'gradlew')
121
+ const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle'
122
+
123
+ await new Promise<void>(async (resolve, reject) => {
124
+ // Auto-detect and set JAVA_HOME (prefer JDK 17) so builds don't require manual environment setup
125
+ const detectedJavaHome = await detectJavaHome().catch(() => undefined)
126
+ const env = Object.assign({}, process.env)
127
+ if (detectedJavaHome) {
128
+ // Override existing JAVA_HOME if detection found a preferably compatible JDK (e.g., JDK 17).
129
+ if (env.JAVA_HOME !== detectedJavaHome) {
130
+ env.JAVA_HOME = detectedJavaHome
131
+ // Also ensure the JDK bin is on PATH so tools like jlink/javac are resolved from the detected JDK
132
+ env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
133
+ console.debug('[android] Overriding JAVA_HOME with detected path:', detectedJavaHome)
134
+ }
135
+ }
136
+
137
+ // Sanitize environment so user shell init scripts are less likely to override our JAVA_HOME.
138
+ try {
139
+ // Remove obvious shell profile hints; avoid touching SDKMAN symlinks or on-disk state.
140
+ delete env.SHELL
141
+ } catch (e) {}
142
+
143
+ // If we detected a compatible JDK, instruct Gradle to use it and avoid daemon reuse
144
+ // Prepare gradle invocation
145
+ const gradleArgs = ['assembleDebug']
146
+ if (detectedJavaHome) {
147
+ gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
148
+ gradleArgs.push('--no-daemon')
149
+ env.GRADLE_JAVA_HOME = detectedJavaHome
150
+ }
151
+
152
+ // Prefer invoking the wrapper directly without a shell to avoid user profile shims (sdkman) re-setting JAVA_HOME
153
+ const wrapperPath = path.join(apkPath, 'gradlew')
154
+ const useWrapper = existsSync(wrapperPath)
155
+ const execCmd = useWrapper ? wrapperPath : gradleCmd
156
+ const spawnOpts: any = { cwd: apkPath, env }
157
+ // When using wrapper, ensure it's executable and invoke directly (no shell)
158
+ if (useWrapper) {
159
+ // Ensure the wrapper is executable; swallow errors from chmod (best-effort).
160
+ await fs.chmod(wrapperPath, 0o755).catch(() => {})
161
+ spawnOpts.shell = false
162
+ } else {
163
+ // if using system 'gradle' allow shell to resolve platform PATH
164
+ spawnOpts.shell = true
165
+ }
166
+
167
+ const proc = spawn(execCmd, gradleArgs, spawnOpts)
168
+ let stderr = ''
169
+ proc.stderr?.on('data', d => stderr += d.toString())
170
+ proc.on('close', code => {
171
+ if (code === 0) resolve()
172
+ else reject(new Error(stderr || `Gradle build failed with code ${code}`))
173
+ })
174
+ proc.on('error', err => reject(err))
175
+ })
176
+
177
+ const built = await findApk(apkPath)
178
+ if (!built) throw new Error('Could not locate built APK after running Gradle')
179
+ apkToInstall = built
180
+ }
181
+
182
+ const output = await execAdb(['install', '-r', apkToInstall], deviceId)
183
+ return { device: deviceInfo, installed: true, output }
184
+ } catch (e) {
185
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) }
186
+ }
187
+ }
188
+
90
189
  async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
91
190
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
92
191
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)