mobile-debug-mcp 0.13.0 → 0.15.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 (103) hide show
  1. package/README.md +2 -2
  2. package/dist/android/interact.js +13 -1
  3. package/dist/android/observe.js +13 -0
  4. package/dist/cli/ios/run-ios-smoke.js +2 -2
  5. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  6. package/dist/interact/android.js +91 -0
  7. package/dist/interact/index.js +37 -0
  8. package/dist/interact/ios.js +120 -0
  9. package/dist/interact/shared/fingerprint.js +72 -0
  10. package/dist/interact/shared/scroll_to_element.js +98 -0
  11. package/dist/ios/interact.js +52 -1
  12. package/dist/ios/observe.js +12 -0
  13. package/dist/manage/android.js +162 -0
  14. package/dist/manage/index.js +364 -0
  15. package/dist/manage/ios.js +353 -0
  16. package/dist/observe/android.js +351 -0
  17. package/dist/observe/fingerprint.js +1 -0
  18. package/dist/observe/index.js +85 -0
  19. package/dist/observe/ios.js +320 -0
  20. package/dist/observe/test/device/logstream-real.js +34 -0
  21. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  22. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  23. package/dist/observe/test/device/test-ui-tree.js +67 -0
  24. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  25. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  26. package/dist/observe/test/unit/logparse.test.js +39 -0
  27. package/dist/observe/test/unit/logstream.test.js +41 -0
  28. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  29. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  30. package/dist/server.js +54 -9
  31. package/dist/shared/fingerprint.js +72 -0
  32. package/dist/shared/scroll_to_element.js +98 -0
  33. package/dist/tools/interact.js +19 -22
  34. package/dist/tools/manage.js +2 -2
  35. package/dist/tools/observe.js +45 -43
  36. package/dist/tools/scroll_to_element.js +98 -0
  37. package/dist/utils/android/utils.js +429 -0
  38. package/dist/utils/cli/idb/check-idb.js +84 -0
  39. package/dist/utils/cli/idb/idb-helper.js +91 -0
  40. package/dist/utils/cli/idb/install-idb.js +82 -0
  41. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  42. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  43. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  44. package/dist/utils/diagnostics.js +1 -1
  45. package/dist/utils/ios/utils.js +301 -0
  46. package/dist/utils/resolve-device.js +2 -2
  47. package/docs/CHANGELOG.md +11 -0
  48. package/docs/tools/TOOLS.md +3 -3
  49. package/docs/tools/interact.md +31 -0
  50. package/docs/tools/observe.md +24 -0
  51. package/package.json +1 -1
  52. package/src/{android/interact.ts → interact/android.ts} +15 -2
  53. package/src/interact/index.ts +47 -0
  54. package/src/{ios/interact.ts → interact/ios.ts} +58 -3
  55. package/src/interact/shared/fingerprint.ts +73 -0
  56. package/src/interact/shared/scroll_to_element.ts +110 -0
  57. package/src/{android/manage.ts → manage/android.ts} +2 -2
  58. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  59. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  60. package/src/{android/observe.ts → observe/android.ts} +14 -26
  61. package/src/observe/index.ts +92 -0
  62. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  63. package/src/server.ts +57 -10
  64. package/src/{android → utils/android}/utils.ts +2 -2
  65. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  66. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  67. package/src/utils/diagnostics.ts +1 -1
  68. package/src/{ios → utils/ios}/utils.ts +2 -2
  69. package/src/utils/resolve-device.ts +2 -2
  70. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  71. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  72. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  73. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  74. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  76. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  77. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  78. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  79. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  80. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  81. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  82. package/test/observe/device/run-scroll-test-android.ts +24 -0
  83. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  84. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  85. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  86. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  87. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  88. package/test/observe/unit/scroll_to_element.test.ts +129 -0
  89. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +3 -3
  90. package/test/unit/index.ts +12 -11
  91. package/src/tools/interact.ts +0 -45
  92. package/src/tools/observe.ts +0 -82
  93. package/test/device/README.md +0 -49
  94. package/test/device/index.ts +0 -27
  95. package/test/device/utils/test-dist.ts +0 -41
  96. package/test/unit/utils/detect-java.test.ts +0 -22
  97. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  98. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  99. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  100. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  101. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  102. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  103. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,301 @@
1
+ import { execFile, spawn, execSync, spawnSync } from "child_process";
2
+ import { promises as fsPromises } from 'fs';
3
+ import path from 'path';
4
+ import { makeEnvSnapshot } from '../diagnostics.js';
5
+ export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun'; }
6
+ export function getConfiguredIdbPath() {
7
+ if (process.env.MCP_IDB_PATH)
8
+ return process.env.MCP_IDB_PATH;
9
+ if (process.env.IDB_PATH)
10
+ return process.env.IDB_PATH;
11
+ const cfgPaths = [
12
+ process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
13
+ `${process.cwd()}/mcp.config.json`
14
+ ];
15
+ try {
16
+ const fs = require('fs');
17
+ for (const p of cfgPaths) {
18
+ if (!p)
19
+ continue;
20
+ try {
21
+ if (fs.existsSync(p)) {
22
+ const raw = fs.readFileSync(p, 'utf8');
23
+ const json = JSON.parse(raw);
24
+ if (json) {
25
+ if (json.idbPath)
26
+ return json.idbPath;
27
+ if (json.IDB_PATH)
28
+ return json.IDB_PATH;
29
+ }
30
+ }
31
+ }
32
+ catch { }
33
+ }
34
+ }
35
+ catch { }
36
+ return undefined;
37
+ }
38
+ export function getIdbCmd() {
39
+ const cfg = getConfiguredIdbPath();
40
+ if (cfg)
41
+ return cfg;
42
+ if (process.env.IDB_PATH)
43
+ return process.env.IDB_PATH;
44
+ try {
45
+ const p = execSync('which idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
46
+ if (p)
47
+ return p;
48
+ }
49
+ catch { }
50
+ try {
51
+ const p2 = execSync('command -v idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
52
+ if (p2)
53
+ return p2;
54
+ }
55
+ catch { }
56
+ // check common user locations
57
+ const common = [
58
+ `${process.env.HOME}/Library/Python/3.9/bin/idb`,
59
+ `${process.env.HOME}/Library/Python/3.10/bin/idb`,
60
+ '/opt/homebrew/bin/idb',
61
+ '/usr/local/bin/idb',
62
+ ];
63
+ for (const c of common) {
64
+ try {
65
+ execSync(`test -x ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] });
66
+ return c;
67
+ }
68
+ catch { }
69
+ }
70
+ return 'idb';
71
+ }
72
+ export async function isIDBInstalled() {
73
+ const cmd = getIdbCmd();
74
+ try {
75
+ execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] });
76
+ return true;
77
+ }
78
+ catch {
79
+ try {
80
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ }
88
+ // Validate bundle ID to prevent any potential injection or invalid characters
89
+ export function validateBundleId(bundleId) {
90
+ if (!bundleId)
91
+ return;
92
+ // Allow alphanumeric, dots, hyphens, and underscores.
93
+ if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
94
+ throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`);
95
+ }
96
+ }
97
+ export function execCommand(args, deviceId = "booted") {
98
+ return new Promise((resolve, reject) => {
99
+ // Use spawn for better stream control and consistency with Android implementation
100
+ const child = spawn(getXcrunCmd(), args);
101
+ let stdout = '';
102
+ let stderr = '';
103
+ if (child.stdout) {
104
+ child.stdout.on('data', (data) => {
105
+ stdout += data.toString();
106
+ });
107
+ }
108
+ if (child.stderr) {
109
+ child.stderr.on('data', (data) => {
110
+ stderr += data.toString();
111
+ });
112
+ }
113
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
114
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
115
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
116
+ const timeout = setTimeout(() => {
117
+ child.kill();
118
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
119
+ }, timeoutMs);
120
+ child.on('close', (code) => {
121
+ clearTimeout(timeout);
122
+ if (code !== 0) {
123
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
124
+ }
125
+ else {
126
+ resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } });
127
+ }
128
+ });
129
+ child.on('error', (err) => {
130
+ clearTimeout(timeout);
131
+ reject(err);
132
+ });
133
+ });
134
+ }
135
+ export function execCommandWithDiagnostics(args, deviceId = "booted") {
136
+ // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
137
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
138
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
139
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT;
140
+ const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs });
141
+ const runResult = {
142
+ exitCode: typeof res.status === 'number' ? res.status : null,
143
+ stdout: res.stdout || '',
144
+ stderr: res.stderr || '',
145
+ envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
146
+ command: getXcrunCmd(),
147
+ args,
148
+ deviceId
149
+ };
150
+ if (res.status !== 0) {
151
+ // include suggested fixes for common errors
152
+ const suggested = [];
153
+ if ((runResult.stderr || '').includes('xcodebuild: error')) {
154
+ suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.');
155
+ }
156
+ if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
157
+ suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.');
158
+ }
159
+ // Return diagnostics object
160
+ return { runResult: { ...runResult, suggestedFixes: suggested } };
161
+ }
162
+ return { runResult: { ...runResult, suggestedFixes: [] } };
163
+ }
164
+ function parseRuntimeName(runtime) {
165
+ // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
166
+ try {
167
+ const parts = runtime.split('.');
168
+ const lastPart = parts[parts.length - 1]; // e.g. "iOS-17-0"
169
+ // Split by hyphen to separate OS from version numbers
170
+ // e.g. "iOS-17-0" -> ["iOS", "17", "0"]
171
+ const segments = lastPart.split('-');
172
+ if (segments.length > 1) {
173
+ const os = segments[0]; // "iOS"
174
+ const version = segments.slice(1).join('.'); // "17.0"
175
+ return `${os} ${version}`;
176
+ }
177
+ return lastPart;
178
+ }
179
+ catch {
180
+ return runtime;
181
+ }
182
+ }
183
+ export async function findAppBundle(dir) {
184
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => []);
185
+ for (const e of entries) {
186
+ const full = path.join(dir, e.name);
187
+ if (e.isDirectory()) {
188
+ if (full.endsWith('.app'))
189
+ return full;
190
+ const found = await findAppBundle(full);
191
+ if (found)
192
+ return found;
193
+ }
194
+ }
195
+ return undefined;
196
+ }
197
+ export async function getIOSDeviceMetadata(deviceId = "booted") {
198
+ return new Promise((resolve) => {
199
+ // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
200
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
201
+ const fallback = {
202
+ platform: "ios",
203
+ id: deviceId,
204
+ osVersion: "Unknown",
205
+ model: "Simulator",
206
+ simulator: true,
207
+ };
208
+ if (err || !stdout) {
209
+ resolve(fallback);
210
+ return;
211
+ }
212
+ try {
213
+ const data = JSON.parse(stdout);
214
+ const devicesMap = data.devices || {};
215
+ for (const runtime in devicesMap) {
216
+ const devices = devicesMap[runtime];
217
+ if (Array.isArray(devices)) {
218
+ for (const device of devices) {
219
+ if (deviceId === "booted" || device.udid === deviceId) {
220
+ resolve({
221
+ platform: "ios",
222
+ id: device.udid,
223
+ osVersion: parseRuntimeName(runtime),
224
+ model: device.name,
225
+ simulator: true,
226
+ });
227
+ return;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ resolve(fallback);
233
+ }
234
+ catch {
235
+ resolve(fallback);
236
+ }
237
+ });
238
+ });
239
+ }
240
+ export async function listIOSDevices(appId) {
241
+ return new Promise((resolve) => {
242
+ // Query all devices and separately query booted devices to mark them
243
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
244
+ if (err || !stdout)
245
+ return resolve([]);
246
+ try {
247
+ const data = JSON.parse(stdout);
248
+ const devicesMap = data.devices || {};
249
+ const out = [];
250
+ const checks = [];
251
+ // Get booted devices set
252
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
253
+ const bootedSet = new Set();
254
+ if (!err2 && stdout2) {
255
+ try {
256
+ const bdata = JSON.parse(stdout2);
257
+ const bmap = bdata.devices || {};
258
+ for (const rt in bmap) {
259
+ const devs = bmap[rt];
260
+ if (Array.isArray(devs))
261
+ for (const d of devs)
262
+ bootedSet.add(d.udid);
263
+ }
264
+ }
265
+ catch { }
266
+ }
267
+ for (const runtime in devicesMap) {
268
+ const devices = devicesMap[runtime];
269
+ if (Array.isArray(devices)) {
270
+ for (const device of devices) {
271
+ const info = {
272
+ platform: 'ios',
273
+ id: device.udid,
274
+ osVersion: parseRuntimeName(runtime),
275
+ model: device.name,
276
+ simulator: true,
277
+ booted: bootedSet.has(device.udid)
278
+ };
279
+ if (appId) {
280
+ // check if installed
281
+ const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
282
+ .then(() => { info.appInstalled = true; })
283
+ .catch(() => { info.appInstalled = false; })
284
+ .then(() => { out.push(info); });
285
+ checks.push(p);
286
+ }
287
+ else {
288
+ out.push(info);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
294
+ });
295
+ }
296
+ catch {
297
+ resolve([]);
298
+ }
299
+ });
300
+ });
301
+ }
@@ -1,5 +1,5 @@
1
- import { listAndroidDevices } from "../android/utils.js";
2
- import { listIOSDevices } from "../ios/utils.js";
1
+ import { listAndroidDevices } from "./android/utils.js";
2
+ import { listIOSDevices } from "./ios/utils.js";
3
3
  function parseNumericVersion(v) {
4
4
  if (!v)
5
5
  return 0;
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.15.0]
6
+ - Reorganised repository for cohesion: merged tool handlers into feature entrypoints (src/observe, src/interact, src/manage) and moved platform helpers and CLI tooling into src/utils/{android,ios,cli}.
7
+ - Added computeScreenFingerprint utility used by observe/interact to normalise UI element significance across platforms (fingerprint shared between Android and iOS implementations).
8
+
9
+ ## [0.14.0]
10
+ - Added `scroll_to_element` tool: platform-aware helper that scrolls until a UI element matching a selector is visible. Supports Android and iOS with configurable options: direction, maxScrolls, and scrollAmount. Includes unit tests and device runners under `test/device/` for manual E2E validation.
11
+ - Moved scroll logic into platform-specific implementations (`src/android/interact.ts`, `src/ios/interact.ts`) and delegated from `src/tools/interact.ts` to centralise platform behaviour.
12
+ - Fixed iOS `idb` swipe arguments and improved visibility detection by using element bounds and device resolution to avoid treating off-screen elements as visible.
13
+ - Consolidated unit tests for `scroll_to_element` into `test/unit/observe/scroll_to_element.test.ts`, and removed older duplicate test files.
14
+
15
+
5
16
  ## [0.13.0]
6
17
  - Fixed a crash in the `start_app` tool by adding validation to ensure `appId` and `platform` are provided.
7
18
 
@@ -4,8 +4,8 @@ This repository groups tool docs into three areas aligned with the codebase: man
4
4
 
5
5
  See:
6
6
 
7
- - docs/manage.md — build, install and device management tools
8
- - docs/observe.md — logs, screenshots and UI inspection tools
9
- - docs/interact.md — UI interaction tools (tap, swipe, type, wait)
7
+ - [mange](manage.md) — build, install and device management tools
8
+ - [observe](observe.md) — logs, screenshots and UI inspection tools
9
+ - [interact](interact.md) — UI interaction tools (tap, swipe, type, wait)
10
10
 
11
11
  For per-tool deep dives, open the linked files above.
@@ -41,3 +41,34 @@ Notes:
41
41
  - swipe: `adb shell input swipe x1 y1 x2 y2 duration`.
42
42
  - type_text: `adb shell input text` (spaces encoded as %s) — may fail for special characters.
43
43
  - press_back: `adb shell input keyevent 4`.
44
+
45
+ ---
46
+
47
+ ## scroll_to_element
48
+
49
+ Description:
50
+ - Scrolls the UI until an element matching the provided selector becomes visible, or until a maximum number of scroll attempts is reached.
51
+ - Delegates platform behaviour to Android and iOS implementations for reliable swipes and UI-tree checks.
52
+
53
+ Input example:
54
+ ```
55
+ { "platform": "android", "selector": { "text": "Offscreen Test Element" }, "direction": "down", "maxScrolls": 10, "scrollAmount": 0.7, "deviceId": "emulator-5554" }
56
+ ```
57
+
58
+ Response example (found):
59
+ ```
60
+ { "success": true, "reason": "element_found", "element": { /* element metadata */ }, "scrollsPerformed": 2 }
61
+ ```
62
+
63
+ Response example (failure - unchanged UI):
64
+ ```
65
+ { "success": false, "reason": "ui_unchanged_after_scroll", "scrollsPerformed": 3 }
66
+ ```
67
+
68
+ Notes:
69
+ - Matching is exact on provided selector fields (text, resourceId, contentDesc, className).
70
+ - Visibility check uses element.bounds intersecting the device resolution when available; falls back to the element.visible flag if bounds/resolution are missing.
71
+ - The tool fingerprints the visible UI between scrolls; if the fingerprint doesn't change after a swipe the tool stops early assuming end-of-list.
72
+ - Android swipe uses `adb shell input swipe` with screen percentage coordinates. iOS swipe uses `idb ui swipe` command; note `idb` swipe does not accept a duration argument.
73
+ - Unit tests are located at `test/unit/observe/scroll_to_element.test.ts` and device runners at `test/device/observe/`.
74
+
@@ -76,6 +76,30 @@ Response:
76
76
 
77
77
  ---
78
78
 
79
+ ## get_screen_fingerprint
80
+ Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
81
+
82
+ Input (optional):
83
+
84
+ ```
85
+ { "platform": "android", "deviceId": "emulator-5554" }
86
+ ```
87
+
88
+ Response:
89
+
90
+ ```json
91
+ { "fingerprint": "<sha256_hex>", "activity": "com.example.app.MainActivity" }
92
+ ```
93
+
94
+ Notes:
95
+ - Uses get_ui_tree and (on Android) get_current_screen as inputs.
96
+ - Normalises visible, interactable or structurally significant elements (class/type, resourceId, text, contentDesc).
97
+ - Trims and lowercases text, filters out likely dynamic values (timestamps, counters).
98
+ - Sorts deterministically (top-to-bottom, left-to-right) and limits elements to 50.
99
+ - Returns fingerprint: null and an error message if the UI tree or activity cannot be retrieved.
100
+
101
+ ---
102
+
79
103
  ## start_log_stream / read_log_stream / stop_log_stream
80
104
  Start a background adb logcat stream and retrieve parsed NDJSON entries.
81
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.15.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": {
@@ -1,6 +1,7 @@
1
1
  import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
- import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
3
- import { AndroidObserve } from "./observe.js"
2
+ import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
3
+ import { AndroidObserve } from "../observe/index.js"
4
+ import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
4
5
 
5
6
 
6
7
  export class AndroidInteract {
@@ -88,4 +89,16 @@ export class AndroidInteract {
88
89
  }
89
90
  }
90
91
 
92
+ async scrollToElement(selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction: 'down' | 'up' = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId?: string) {
93
+ return await scrollToElementShared({
94
+ selector,
95
+ direction,
96
+ maxScrolls,
97
+ scrollAmount,
98
+ deviceId,
99
+ fetchTree: async () => await this.observe.getUITree(deviceId),
100
+ swipe: async (x1: number, y1: number, x2: number, y2: number, duration: number, devId?: string) => await this.swipe(x1, y1, x2, y2, duration, devId)
101
+ })
102
+ }
103
+
91
104
  }
@@ -0,0 +1,47 @@
1
+ import { AndroidInteract } from './android.js';
2
+ import { iOSInteract } from './ios.js';
3
+ export { AndroidInteract, iOSInteract };
4
+
5
+ import { resolveTargetDevice } from '../utils/resolve-device.js'
6
+
7
+ export class ToolsInteract {
8
+
9
+ private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
10
+ const effectivePlatform = platform || 'android'
11
+ const resolved = await resolveTargetDevice({ platform: effectivePlatform as 'android' | 'ios', deviceId })
12
+ const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract()
13
+ return { interact: interact as any, resolved, platform: effectivePlatform }
14
+ }
15
+
16
+ static async waitForElementHandler({ platform, text, timeout, deviceId }: { platform: 'android' | 'ios', text: string, timeout?: number, deviceId?: string }) {
17
+ const effectiveTimeout = timeout ?? 10000
18
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
19
+ return await interact.waitForElement(text, effectiveTimeout, resolved.id)
20
+ }
21
+
22
+ static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
23
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
24
+ return await interact.tap(x, y, resolved.id)
25
+ }
26
+
27
+ static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
28
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
29
+ return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
30
+ }
31
+
32
+ static async typeTextHandler({ text, deviceId }: { text: string, deviceId?: string }) {
33
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
34
+ return await new AndroidInteract().typeText(text, resolved.id)
35
+ }
36
+
37
+ static async pressBackHandler({ deviceId }: { deviceId?: string }) {
38
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
39
+ return await new AndroidInteract().pressBack(resolved.id)
40
+ }
41
+
42
+ static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }: { platform: 'android' | 'ios', selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction?: 'down' | 'up', maxScrolls?: number, scrollAmount?: number, deviceId?: string }) {
43
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
44
+ return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id)
45
+ }
46
+
47
+ }
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "child_process"
2
- import { WaitForElementResponse, TapResponse } from "../types.js"
3
- import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js"
4
- import { iOSObserve } from "./observe.js"
2
+ import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
3
+ import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
4
+ import { iOSObserve } from "../observe/index.js"
5
+ import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
5
6
 
6
7
  export class iOSInteract {
7
8
  private observe = new iOSObserve();
@@ -75,4 +76,58 @@ export class iOSInteract {
75
76
  return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
76
77
  }
77
78
  }
79
+
80
+ async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId: string = "booted"): Promise<SwipeResponse> {
81
+ const device = await getIOSDeviceMetadata(deviceId);
82
+ // Use shared helper to detect idb
83
+ const idbExists = await isIDBInstalled();
84
+
85
+ if (!idbExists) {
86
+ return {
87
+ device,
88
+ success: false,
89
+ start: [x1, y1],
90
+ end: [x2, y2],
91
+ duration,
92
+ error: "iOS swipe requires 'idb' (iOS Device Bridge)."
93
+ }
94
+ }
95
+
96
+ try {
97
+ const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
98
+ // idb 'ui swipe' does not accept a duration parameter; use coordinates only
99
+ const args: string[] = ['ui', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString()];
100
+ if (targetUdid) {
101
+ args.push('--udid', targetUdid);
102
+ }
103
+
104
+ await new Promise<void>((resolve, reject) => {
105
+ const proc = spawn(getIdbCmd(), args);
106
+ let stderr = '';
107
+ proc.stderr.on('data', d => stderr += d.toString());
108
+ proc.on('close', code => {
109
+ if (code === 0) resolve();
110
+ else reject(new Error(`idb ui swipe failed: ${stderr}`));
111
+ });
112
+ proc.on('error', err => reject(err));
113
+ });
114
+
115
+ return { device, success: true, start: [x1, y1], end: [x2, y2], duration };
116
+ } catch (e) {
117
+ return { device, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
118
+ }
119
+ }
120
+
121
+ async scrollToElement(selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction: 'down' | 'up' = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId: string = 'booted') {
122
+ return await scrollToElementShared({
123
+ selector,
124
+ direction,
125
+ maxScrolls,
126
+ scrollAmount,
127
+ deviceId,
128
+ fetchTree: async () => await this.observe.getUITree(deviceId),
129
+ swipe: async (x1: number, y1: number, x2: number, y2: number, duration: number, devId?: string) => await this.swipe(x1, y1, x2, y2, duration, devId)
130
+ })
131
+ }
78
132
  }
133
+
@@ -0,0 +1,73 @@
1
+ import crypto from 'crypto'
2
+ import { GetUITreeResponse, GetCurrentScreenResponse, UIElement } from '../../types.js'
3
+
4
+ const ANDROID_STRUCTURAL_TYPES = ['Window','Application','View','ViewGroup','LinearLayout','FrameLayout','RelativeLayout','ScrollView','RecyclerView','TextView','ImageView']
5
+ const IOS_STRUCTURAL_TYPES = ['Window','Application','View','ViewController','UITableView','UICollectionView','UILabel','UIImageView','UIView','UIWindow','UIStackView','UITextView','UITableViewCell']
6
+
7
+ function isDynamicText(t?: string): boolean {
8
+ if (!t) return false
9
+ const txt = t.trim()
10
+ if (!txt) return false
11
+ if (/\b\d{1,2}:\d{2}\b/.test(txt)) return true
12
+ if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt)) return true
13
+ if (/^\d+(?:\.\d+)?%$/.test(txt)) return true
14
+ if (/^\d+$/.test(txt)) return true
15
+ if (/^[\d,]{1,10}$/.test(txt)) return true
16
+ return false
17
+ }
18
+
19
+ function normalizeElement(e: UIElement) {
20
+ return {
21
+ type: (e.type || '').toString(),
22
+ resourceId: (e.resourceId || '').toString(),
23
+ text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
24
+ contentDesc: (e.contentDescription || '').toString(),
25
+ bounds: Array.isArray(e.bounds) ? e.bounds.slice(0,4).map((n:any)=>Number(n)||0) : [0,0,0,0]
26
+ }
27
+ }
28
+
29
+ export function computeScreenFingerprint(tree: GetUITreeResponse, current: GetCurrentScreenResponse | null, platform: 'android' | 'ios', limit: number = 50): { fingerprint: string | null; activity?: string; error?: string } {
30
+ try {
31
+ if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
32
+
33
+ const activity = current && (current.activity || (current as any).shortActivity) ? (current.activity || (current as any).shortActivity) : ''
34
+
35
+ const candidates: UIElement[] = (tree.elements || []).filter(e => {
36
+ if (!e) return false
37
+ if (!e.visible) return false
38
+ const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0
39
+ const hasResource = !!e.resourceId
40
+ const interactable = !!e.clickable || !!e.enabled
41
+ const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES
42
+ const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '')
43
+ return interactable || structurallySignificant
44
+ }) as UIElement[]
45
+
46
+ const normalized = candidates.map(normalizeElement)
47
+
48
+ const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0))
49
+
50
+ filteredNormalized.sort((a,b) => {
51
+ const ay = (a.bounds && a.bounds[1]) || 0
52
+ const by = (b.bounds && b.bounds[1]) || 0
53
+ if (ay !== by) return ay - by
54
+ const ax = (a.bounds && a.bounds[0]) || 0
55
+ const bx = (b.bounds && b.bounds[0]) || 0
56
+ return ax - bx
57
+ })
58
+
59
+ const limited = filteredNormalized.slice(0, Math.max(0, limit))
60
+
61
+ const payload = {
62
+ activity: platform === 'android' ? (activity || '') : '',
63
+ resolution: (tree as any).resolution || { width: 0, height: 0 },
64
+ elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
65
+ }
66
+
67
+ const combined = JSON.stringify(payload)
68
+ const hash = crypto.createHash('sha256').update(combined).digest('hex')
69
+ return { fingerprint: hash, activity: activity }
70
+ } catch (e) {
71
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
72
+ }
73
+ }