mobile-debug-mcp 0.14.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 (98) hide show
  1. package/dist/android/interact.js +2 -2
  2. package/dist/android/observe.js +13 -0
  3. package/dist/cli/ios/run-ios-smoke.js +2 -2
  4. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  5. package/dist/interact/android.js +91 -0
  6. package/dist/interact/index.js +37 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +72 -0
  9. package/dist/interact/shared/scroll_to_element.js +98 -0
  10. package/dist/ios/interact.js +2 -2
  11. package/dist/ios/observe.js +12 -0
  12. package/dist/manage/android.js +162 -0
  13. package/dist/manage/index.js +364 -0
  14. package/dist/manage/ios.js +353 -0
  15. package/dist/observe/android.js +351 -0
  16. package/dist/observe/fingerprint.js +1 -0
  17. package/dist/observe/index.js +85 -0
  18. package/dist/observe/ios.js +320 -0
  19. package/dist/observe/test/device/logstream-real.js +34 -0
  20. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  21. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  22. package/dist/observe/test/device/test-ui-tree.js +67 -0
  23. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  24. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  25. package/dist/observe/test/unit/logparse.test.js +39 -0
  26. package/dist/observe/test/unit/logstream.test.js +41 -0
  27. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  28. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  29. package/dist/server.js +21 -5
  30. package/dist/shared/fingerprint.js +72 -0
  31. package/dist/shared/scroll_to_element.js +98 -0
  32. package/dist/tools/interact.js +2 -2
  33. package/dist/tools/manage.js +2 -2
  34. package/dist/tools/observe.js +45 -43
  35. package/dist/utils/android/utils.js +429 -0
  36. package/dist/utils/cli/idb/check-idb.js +84 -0
  37. package/dist/utils/cli/idb/idb-helper.js +91 -0
  38. package/dist/utils/cli/idb/install-idb.js +82 -0
  39. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  40. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  41. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  42. package/dist/utils/diagnostics.js +1 -1
  43. package/dist/utils/ios/utils.js +301 -0
  44. package/dist/utils/resolve-device.js +2 -2
  45. package/docs/CHANGELOG.md +4 -0
  46. package/docs/tools/observe.md +24 -0
  47. package/package.json +1 -1
  48. package/src/{android/interact.ts → interact/android.ts} +3 -3
  49. package/src/{tools/interact.ts → interact/index.ts} +4 -3
  50. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  51. package/src/interact/shared/fingerprint.ts +73 -0
  52. package/src/{tools → interact/shared}/scroll_to_element.ts +1 -1
  53. package/src/{android/manage.ts → manage/android.ts} +2 -2
  54. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  55. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  56. package/src/{android/observe.ts → observe/android.ts} +14 -26
  57. package/src/observe/index.ts +92 -0
  58. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  59. package/src/server.ts +23 -6
  60. package/src/{android → utils/android}/utils.ts +2 -2
  61. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  62. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  63. package/src/utils/diagnostics.ts +1 -1
  64. package/src/{ios → utils/ios}/utils.ts +2 -2
  65. package/src/utils/resolve-device.ts +2 -2
  66. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  67. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  68. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  69. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  70. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  71. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  72. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  73. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  74. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  76. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  77. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  78. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  79. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  80. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  81. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  82. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  83. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  84. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  85. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  86. package/test/unit/index.ts +12 -11
  87. package/src/tools/observe.ts +0 -82
  88. package/test/device/README.md +0 -49
  89. package/test/device/index.ts +0 -27
  90. package/test/device/utils/test-dist.ts +0 -41
  91. package/test/unit/utils/detect-java.test.ts +0 -22
  92. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  93. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  94. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  95. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  96. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  97. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  98. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { spawn } from 'child_process';
5
+ import { getIdbCmd, isIDBInstalled, commandWhich } from '../idb/idb-helper.js';
6
+ async function exists(p) {
7
+ try {
8
+ await fs.promises.access(p);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ async function findFirst(root, patterns, maxDepth = 4) {
16
+ const queue = [{ dir: root, depth: 0 }];
17
+ while (queue.length) {
18
+ const { dir, depth } = queue.shift();
19
+ try {
20
+ console.error('DEBUG findFirst: reading dir', dir, 'depth', depth);
21
+ const ents = await fs.promises.readdir(dir, { withFileTypes: true });
22
+ console.error('DEBUG findFirst: entries', ents.map(e => e.name));
23
+ for (const e of ents) {
24
+ const full = path.join(dir, e.name);
25
+ for (const p of patterns) {
26
+ if (e.name.endsWith(p)) {
27
+ console.error('DEBUG findFirst: matched', full);
28
+ return full;
29
+ }
30
+ }
31
+ if (e.isDirectory() && depth < maxDepth)
32
+ queue.push({ dir: full, depth: depth + 1 });
33
+ }
34
+ }
35
+ catch (err) {
36
+ console.error('DEBUG findFirst: read failed for', dir, err);
37
+ // ignore
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ function startCompanionIfNeeded(companionPath, udid) {
43
+ if (!companionPath || !udid)
44
+ return { started: false, error: 'missing companion or udid' };
45
+ try {
46
+ const child = spawn(companionPath, ['--udid', udid], { detached: true, stdio: 'ignore' });
47
+ child.unref();
48
+ return { started: true };
49
+ }
50
+ catch (e) {
51
+ return { started: false, error: e.message };
52
+ }
53
+ }
54
+ async function main() {
55
+ const args = process.argv.slice(2);
56
+ let projectArg;
57
+ let udid;
58
+ let startCompanion = false;
59
+ let kmpBuild = null;
60
+ for (let i = 0; i < args.length; i++) {
61
+ const a = args[i];
62
+ if (a === '--project' && args[i + 1]) {
63
+ projectArg = args[i + 1];
64
+ i++;
65
+ }
66
+ else if (a === '--udid' && args[i + 1]) {
67
+ udid = args[i + 1];
68
+ i++;
69
+ }
70
+ else if (a === '--start-companion')
71
+ startCompanion = true;
72
+ else if (!projectArg)
73
+ projectArg = a;
74
+ }
75
+ const cwd = process.cwd();
76
+ const projectRoot = projectArg ? path.resolve(projectArg) : cwd;
77
+ // If user passed a direct .xcodeproj or .xcworkspace path, accept it as the projectFile
78
+ let projectFile = null;
79
+ try {
80
+ const stat = await fs.promises.stat(projectRoot);
81
+ if (stat.isFile() && (projectRoot.endsWith('.xcodeproj') || projectRoot.endsWith('.xcworkspace'))) {
82
+ projectFile = projectRoot;
83
+ }
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ // detect project if not a direct file
89
+ if (!projectFile) {
90
+ const projectFound = await exists(projectRoot);
91
+ if (projectFound) {
92
+ projectFile = await findFirst(projectRoot, ['.xcworkspace', '.xcodeproj'], 2);
93
+ }
94
+ else {
95
+ // attempt to find under cwd
96
+ projectFile = await findFirst(cwd, ['.xcworkspace', '.xcodeproj'], 3);
97
+ }
98
+ }
99
+ // 2) KMP Shared.framework detection (search for *.framework named Shared.framework)
100
+ let kmpFramework = null;
101
+ const projectSearchRoot = projectFile ? (fs.existsSync(projectFile) && fs.lstatSync(projectFile).isDirectory() ? projectFile : path.dirname(projectFile)) : cwd;
102
+ kmpFramework = await findFirst(projectSearchRoot, ['Shared.framework'], 5);
103
+ if (!kmpFramework)
104
+ kmpFramework = await findFirst(cwd, ['Shared.framework'], 6);
105
+ // 3) idb detection
106
+ const idbPath = getIdbCmd();
107
+ const idbAvailable = idbPath ? isIDBInstalled() : false;
108
+ // 4) idb_companion
109
+ const companionPath = commandWhich('idb_companion');
110
+ const companionAvailable = !!companionPath;
111
+ const suggestions = [];
112
+ if (!projectFile)
113
+ suggestions.push('Provide correct project path or ensure .xcodeproj/.xcworkspace exists in project dir');
114
+ if (!kmpFramework)
115
+ suggestions.push('Run KMP Gradle task to produce Shared.framework before xcodebuild (e.g., :shared:embedAndSignAppleFrameworkForXcode)');
116
+ if (!idbAvailable)
117
+ suggestions.push('Ensure idb is in PATH or set MCP_IDB_PATH / IDB_PATH');
118
+ if (!companionAvailable)
119
+ suggestions.push('Install idb_companion and ensure it is in PATH');
120
+ const result = {
121
+ ok: !!projectFile && !!idbAvailable,
122
+ project: {
123
+ root: projectRoot,
124
+ found: !!projectFile,
125
+ projectFile: projectFile
126
+ },
127
+ kmp: {
128
+ found: !!kmpFramework,
129
+ path: kmpFramework,
130
+ build: kmpBuild
131
+ },
132
+ idb: {
133
+ cmd: idbPath,
134
+ installed: idbAvailable
135
+ },
136
+ idb_companion: {
137
+ cmd: companionPath,
138
+ installed: companionAvailable
139
+ },
140
+ suggestions
141
+ };
142
+ if (startCompanion && udid) {
143
+ const started = startCompanionIfNeeded(companionPath, udid);
144
+ result.idb_companion.start = started;
145
+ if (started.started)
146
+ result.ok = result.ok && true;
147
+ }
148
+ console.log(JSON.stringify(result, null, 2));
149
+ process.exit(result.ok ? 0 : 2);
150
+ }
151
+ main().catch(e => {
152
+ // Report structured error on stdout (avoid noisy stderr in normal runs)
153
+ console.log(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }, null, 2));
154
+ process.exit(2);
155
+ });
@@ -0,0 +1,28 @@
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSManage } from '../../../manage/index.js';
3
+ async function main() {
4
+ const appId = process.argv[2] || 'com.apple.springboard';
5
+ const deviceId = 'booted';
6
+ const obs = new iOSObserve();
7
+ const manage = new iOSManage();
8
+ try {
9
+ console.log('[1] startApp ->', appId);
10
+ const start = await manage.startApp(appId, deviceId);
11
+ console.log('start result:', start);
12
+ console.log('[2] captureScreenshot');
13
+ const shot = await obs.captureScreenshot(deviceId);
14
+ console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0);
15
+ console.log('[3] getLogs');
16
+ const logs = await obs.getLogs(appId, undefined);
17
+ console.log('logs count:', logs.logCount);
18
+ console.log('[4] terminateApp');
19
+ const term = await manage.terminateApp(appId, deviceId);
20
+ console.log('terminate:', term);
21
+ console.log('SMOKE OK');
22
+ }
23
+ catch (err) {
24
+ console.error('SMOKE ERROR:', err instanceof Error ? err.message : String(err));
25
+ process.exit(1);
26
+ }
27
+ }
28
+ main();
@@ -0,0 +1,29 @@
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSInteract } from '../../../interact/index.js';
3
+ async function main() {
4
+ const deviceId = 'booted';
5
+ const obs = new iOSObserve();
6
+ const interact = new iOSInteract();
7
+ console.log('Fetching UI tree...');
8
+ const tree = await obs.getUITree(deviceId);
9
+ if (tree.error) {
10
+ console.error('getUITree error:', tree.error);
11
+ process.exit(2);
12
+ }
13
+ console.log('Elements found:', tree.elements.length);
14
+ if (!tree.elements || tree.elements.length === 0) {
15
+ console.error('No elements found; aborting');
16
+ process.exit(3);
17
+ }
18
+ const clickable = tree.elements.find((e) => e.clickable) || tree.elements[0];
19
+ console.log('Using element:', clickable.text || '(no text)', 'clickable=', clickable.clickable, 'center=', clickable.center);
20
+ const [x, y] = clickable.center || [0, 0];
21
+ console.log(`Tapping at ${x},${y}...`);
22
+ const res = await interact.tap(x, y, deviceId);
23
+ console.log('Tap result:', res);
24
+ if (res.success)
25
+ process.exit(0);
26
+ else
27
+ process.exit(4);
28
+ }
29
+ main();
@@ -25,7 +25,7 @@ export class DiagnosticError extends Error {
25
25
  }
26
26
  // Exec ADB with diagnostics — moved from src/android/diagnostics.ts
27
27
  import { spawnSync } from 'child_process';
28
- import { getAdbCmd } from '../android/utils.js';
28
+ import { getAdbCmd } from './android/utils.js';
29
29
  export function execAdbWithDiagnostics(args, deviceId) {
30
30
  const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
31
31
  const timeout = 120000;
@@ -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,10 @@
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
+
5
9
  ## [0.14.0]
6
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.
7
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.
@@ -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.14.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,7 +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"
4
- import { scrollToElementShared } from "../tools/scroll_to_element.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"
5
5
 
6
6
 
7
7
  export class AndroidInteract {
@@ -1,6 +1,8 @@
1
+ import { AndroidInteract } from './android.js';
2
+ import { iOSInteract } from './ios.js';
3
+ export { AndroidInteract, iOSInteract };
4
+
1
5
  import { resolveTargetDevice } from '../utils/resolve-device.js'
2
- import { AndroidInteract } from '../android/interact.js'
3
- import { iOSInteract } from '../ios/interact.js'
4
6
 
5
7
  export class ToolsInteract {
6
8
 
@@ -43,4 +45,3 @@ export class ToolsInteract {
43
45
  }
44
46
 
45
47
  }
46
-
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "child_process"
2
2
  import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
3
- import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js"
4
- import { iOSObserve } from "./observe.js"
5
- import { scrollToElementShared } from "../tools/scroll_to_element.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"
6
6
 
7
7
  export class iOSInteract {
8
8
  private observe = new iOSObserve();