mobile-debug-mcp 0.11.0 → 0.12.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 (56) hide show
  1. package/README.md +18 -5
  2. package/dist/android/diagnostics.js +24 -0
  3. package/dist/android/manage.js +33 -8
  4. package/dist/android/observe.js +4 -4
  5. package/dist/android/utils.js +3 -3
  6. package/dist/ios/interact.js +4 -8
  7. package/dist/ios/manage.js +50 -26
  8. package/dist/ios/observe.js +24 -15
  9. package/dist/ios/utils.js +121 -8
  10. package/dist/server.js +18 -0
  11. package/dist/utils/diagnostics.js +25 -0
  12. package/docs/CHANGELOG.md +10 -0
  13. package/eslint.config.js +22 -1
  14. package/package.json +8 -5
  15. package/scripts/check-idb.js +83 -0
  16. package/scripts/check-idb.ts +73 -0
  17. package/scripts/idb-helper.ts +76 -0
  18. package/scripts/install-idb.js +88 -0
  19. package/scripts/install-idb.ts +90 -0
  20. package/scripts/run-ios-smoke.ts +34 -0
  21. package/scripts/run-ios-ui-tree-tap.ts +33 -0
  22. package/src/android/diagnostics.ts +23 -0
  23. package/src/android/manage.ts +30 -8
  24. package/src/android/observe.ts +4 -4
  25. package/src/android/utils.ts +3 -3
  26. package/src/ios/interact.ts +4 -8
  27. package/src/ios/manage.ts +69 -48
  28. package/src/ios/observe.ts +24 -16
  29. package/src/ios/utils.ts +109 -8
  30. package/src/server.ts +19 -0
  31. package/src/types.ts +9 -0
  32. package/src/utils/diagnostics.ts +36 -0
  33. package/test/device/README.md +49 -0
  34. package/test/device/index.ts +27 -0
  35. package/test/device/manage/run-build-install-ios.ts +82 -0
  36. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  37. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  38. package/test/{integration → device/utils}/test-dist.ts +2 -2
  39. package/test/unit/index.ts +10 -6
  40. package/test/unit/{build.test.ts → manage/build.test.ts} +16 -17
  41. package/test/unit/{build_and_install.test.ts → manage/build_and_install.test.ts} +20 -18
  42. package/test/unit/manage/diagnostics.test.ts +85 -0
  43. package/test/unit/{install.test.ts → manage/install.test.ts} +26 -17
  44. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  45. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +2 -2
  46. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  47. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  48. package/tsconfig.json +2 -1
  49. package/test/integration/index.ts +0 -8
  50. package/test/integration/test-dist.mjs +0 -41
  51. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  52. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  53. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  54. /package/test/{integration → device/observe}/logstream-real.ts +0 -0
  55. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  56. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
- # Mobile Debug MCP
1
+ # Mobile Dev Agent Tools
2
2
 
3
3
  A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
4
4
 
5
- > **Note:** iOS support is currently an untested Work In Progress (WIP). Please use with caution and report any issues.
5
+ > **Note:** iOS support is currently only tested on simulator. Please use with caution and report any issues.
6
6
 
7
7
  ## Requirements
8
8
 
9
9
  - Node.js >= 18
10
- - Android SDK (adb) for Android support
10
+ - [Android SDK](https://developer.android.com/studio) (adb) for Android support
11
11
  - Xcode command-line tools for iOS support
12
- - Optional: idb for enhanced iOS device support
12
+ - [idb](https://github.com/facebook/idb) for iOS device support
13
13
 
14
14
  ## Configuration example
15
15
 
@@ -19,12 +19,16 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
19
19
  "mobile-debug": {
20
20
  "command": "npx",
21
21
  "args": ["--yes","mobile-debug-mcp","server"],
22
- "env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun" }
22
+ "env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun", "IDB_PATH": "/path/to/idb" }
23
23
  }
24
24
  }
25
25
  }
26
26
  ```
27
27
  ## Usage
28
+
29
+ Example:
30
+ After a crash tell the agent the following:
31
+
28
32
  I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
29
33
 
30
34
  ## Docs
@@ -35,3 +39,12 @@ I have a crash on the app, can you diagnose it, fix and validate using the mcp t
35
39
  ## License
36
40
 
37
41
  MIT
42
+
43
+ ## IDB/ADB healthcheck and diagnostics
44
+
45
+ The agent provides healthcheck and optional auto-install scripts for iOS (idb) and Android (adb).
46
+
47
+ - Run `npm run healthcheck` to verify idb is available. Set `MCP_AUTO_INSTALL_IDB=true` to allow the installer to run in CI or non-interactive environments.
48
+ - Override detection with `IDB_PATH` or `ADB_PATH` environment variables.
49
+ - Tools now return structured diagnostics on failures: { exitCode, stdout, stderr, command, args, envSnapshot, suggestedFixes } which helps agents decide corrective actions.
50
+
@@ -0,0 +1,24 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { getAdbCmd } from './utils.js';
3
+ import { makeEnvSnapshot } from '../utils/diagnostics.js';
4
+ export function execAdbWithDiagnostics(args, deviceId) {
5
+ const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
6
+ const timeout = 120000;
7
+ const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout });
8
+ const runResult = {
9
+ exitCode: typeof res.status === 'number' ? res.status : null,
10
+ stdout: res.stdout || '',
11
+ stderr: res.stderr || '',
12
+ envSnapshot: makeEnvSnapshot(['PATH', 'ADB_PATH', 'HOME', 'JAVA_HOME']),
13
+ command: getAdbCmd(),
14
+ args: adbArgs,
15
+ suggestedFixes: []
16
+ };
17
+ if (res.status !== 0) {
18
+ if ((runResult.stderr || '').includes('device not found'))
19
+ runResult.suggestedFixes.push('Ensure device is connected and adb is authorized (adb devices)');
20
+ if ((runResult.stderr || '').includes('No such file or directory'))
21
+ runResult.suggestedFixes.push('Verify ADB_PATH or that adb is installed');
22
+ }
23
+ return { runResult };
24
+ }
@@ -3,6 +3,7 @@ import { spawn } from 'child_process';
3
3
  import path from 'path';
4
4
  import { existsSync } from 'fs';
5
5
  import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js';
6
+ import { execAdbWithDiagnostics } from './diagnostics.js';
6
7
  import { detectJavaHome } from '../utils/java.js';
7
8
  export class AndroidManage {
8
9
  async build(projectPath, _variant) {
@@ -34,8 +35,8 @@ export class AndroidManage {
34
35
  async installApp(apkPath, deviceId) {
35
36
  const metadata = await getAndroidDeviceMetadata('', deviceId);
36
37
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
38
+ let apkToInstall = apkPath;
37
39
  try {
38
- let apkToInstall = apkPath;
39
40
  const stat = await fs.stat(apkPath).catch(() => null);
40
41
  if (stat && stat.isDirectory()) {
41
42
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
@@ -104,20 +105,38 @@ export class AndroidManage {
104
105
  return { device: deviceInfo, installed: true, output: pmOut };
105
106
  }
106
107
  catch (e) {
107
- return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
108
+ // gather diagnostics for attempted adb operations
109
+ const basename = path.basename(apkToInstall);
110
+ const remotePath = `/data/local/tmp/${basename}`;
111
+ const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId);
112
+ const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId);
113
+ const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId);
114
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
108
115
  }
109
116
  }
110
117
  async startApp(appId, deviceId) {
111
118
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
112
119
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
113
- await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
114
- return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
120
+ try {
121
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
122
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
123
+ }
124
+ catch (e) {
125
+ const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
126
+ return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
127
+ }
115
128
  }
116
129
  async terminateApp(appId, deviceId) {
117
130
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
118
131
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
119
- await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
120
- return { device: deviceInfo, appTerminated: true };
132
+ try {
133
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
134
+ return { device: deviceInfo, appTerminated: true };
135
+ }
136
+ catch (e) {
137
+ const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
138
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
139
+ }
121
140
  }
122
141
  async restartApp(appId, deviceId) {
123
142
  await this.terminateApp(appId, deviceId);
@@ -131,7 +150,13 @@ export class AndroidManage {
131
150
  async resetAppData(appId, deviceId) {
132
151
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
133
152
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
134
- const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
135
- return { device: deviceInfo, dataCleared: output === 'Success' };
153
+ try {
154
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
155
+ return { device: deviceInfo, dataCleared: output === 'Success' };
156
+ }
157
+ catch (e) {
158
+ const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId);
159
+ return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
160
+ }
136
161
  }
137
162
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "child_process";
2
2
  import { XMLParser } from "fast-xml-parser";
3
- import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js";
3
+ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js";
4
4
  import { createWriteStream } from "fs";
5
5
  import { promises as fsPromises } from "fs";
6
6
  import path from "path";
@@ -67,7 +67,7 @@ export class AndroidObserve {
67
67
  };
68
68
  }
69
69
  catch (e) {
70
- const errorMessage = `Failed to get UI tree. ADB Path: '${ADB}'. Error: ${e instanceof Error ? e.message : String(e)}`;
70
+ const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
71
71
  console.error(errorMessage);
72
72
  return {
73
73
  device: deviceInfo,
@@ -115,7 +115,7 @@ export class AndroidObserve {
115
115
  // Need to construct ADB args manually since spawn handles it
116
116
  const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
117
117
  // Using spawn for screencap as well to ensure consistent process handling
118
- const child = spawn(ADB, args);
118
+ const child = spawn(getAdbCmd(), args);
119
119
  const chunks = [];
120
120
  let stderr = '';
121
121
  child.stdout.on('data', (chunk) => {
@@ -245,7 +245,7 @@ export class AndroidObserve {
245
245
  activeLogStreams.delete(sessionId);
246
246
  }
247
247
  const args = ['logcat', `--pid=${pid}`, filter];
248
- const proc = spawn(ADB, args);
248
+ const proc = spawn(getAdbCmd(), args);
249
249
  const tmpDir = process.env.TMPDIR || '/tmp';
250
250
  const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
251
251
  const stream = createWriteStream(file, { flags: 'a' });
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
2
2
  import { promises as fsPromises, existsSync } from 'fs';
3
3
  import path from 'path';
4
4
  import { detectJavaHome } from '../utils/java.js';
5
- export const ADB = process.env.ADB_PATH || 'adb';
5
+ export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
6
6
  /**
7
7
  * Prepare Gradle execution options for building an Android project.
8
8
  * Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
@@ -72,7 +72,7 @@ export function execAdb(args, deviceId, options = {}) {
72
72
  // Extract timeout from options if present, otherwise pass options to spawn
73
73
  const { timeout: customTimeout, ...spawnOptions } = options;
74
74
  // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
75
- const child = spawn(ADB, adbArgs, spawnOptions);
75
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
76
76
  let stdout = '';
77
77
  let stderr = '';
78
78
  if (child.stdout) {
@@ -112,7 +112,7 @@ export function spawnAdb(args, deviceId, options = {}) {
112
112
  const adbArgs = getAdbArgs(args, deviceId);
113
113
  return new Promise((resolve, reject) => {
114
114
  const { timeout: customTimeout, ...spawnOptions } = options;
115
- const child = spawn(ADB, adbArgs, spawnOptions);
115
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
116
116
  let stdout = '';
117
117
  let stderr = '';
118
118
  if (child.stdout)
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { getIOSDeviceMetadata, IDB } from "./utils.js";
2
+ import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js";
3
3
  import { iOSObserve } from "./observe.js";
4
4
  export class iOSInteract {
5
5
  observe = new iOSObserve();
@@ -31,12 +31,8 @@ export class iOSInteract {
31
31
  }
32
32
  async tap(x, y, deviceId = "booted") {
33
33
  const device = await getIOSDeviceMetadata(deviceId);
34
- // Check for idb
35
- const child = spawn(IDB, ['--version']);
36
- const idbExists = await new Promise((resolve) => {
37
- child.on('error', () => resolve(false));
38
- child.on('close', (code) => resolve(code === 0));
39
- });
34
+ // Use shared helper to detect idb
35
+ const idbExists = await isIDBInstalled();
40
36
  if (!idbExists) {
41
37
  return {
42
38
  device,
@@ -53,7 +49,7 @@ export class iOSInteract {
53
49
  args.push('--udid', targetUdid);
54
50
  }
55
51
  await new Promise((resolve, reject) => {
56
- const proc = spawn(IDB, args);
52
+ const proc = spawn(getIdbCmd(), args);
57
53
  let stderr = '';
58
54
  proc.stderr.on('data', d => stderr += d.toString());
59
55
  proc.on('close', code => {
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from "fs";
2
2
  import { spawn } from "child_process";
3
- import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB, findAppBundle } from "./utils.js";
3
+ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js";
4
4
  import path from "path";
5
5
  export class iOSManage {
6
6
  async build(projectPath, _variant) {
@@ -23,7 +23,8 @@ export class iOSManage {
23
23
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
24
24
  }
25
25
  await new Promise((resolve, reject) => {
26
- const proc = spawn('xcodebuild', buildArgs, { cwd: projectPath });
26
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
27
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
27
28
  let stderr = '';
28
29
  proc.stderr?.on('data', d => stderr += d.toString());
29
30
  proc.on('close', code => {
@@ -71,15 +72,18 @@ export class iOSManage {
71
72
  return { device, installed: true, output: res.output };
72
73
  }
73
74
  catch (e) {
75
+ // Gather diagnostics for simctl failure
76
+ const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId);
74
77
  try {
75
- const child = spawn(IDB, ['--version']);
78
+ const child = spawn(getIdbCmd(), ['--version']);
76
79
  const idbExists = await new Promise((resolve) => {
77
80
  child.on('error', () => resolve(false));
78
81
  child.on('close', (code) => resolve(code === 0));
79
82
  });
80
83
  if (idbExists) {
84
+ // attempt idb install via spawn but include diagnostics
81
85
  await new Promise((resolve, reject) => {
82
- const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
86
+ const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
83
87
  let stderr = '';
84
88
  proc.stderr.on('data', d => stderr += d.toString());
85
89
  proc.on('close', code => {
@@ -94,7 +98,7 @@ export class iOSManage {
94
98
  }
95
99
  }
96
100
  catch { }
97
- return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
101
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
98
102
  }
99
103
  }
100
104
  catch (e) {
@@ -103,15 +107,29 @@ export class iOSManage {
103
107
  }
104
108
  async startApp(bundleId, deviceId = "booted") {
105
109
  validateBundleId(bundleId);
106
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
107
- const device = await getIOSDeviceMetadata(deviceId);
108
- return { device, appStarted: !!result.output, launchTimeMs: 1000 };
110
+ try {
111
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
112
+ const device = await getIOSDeviceMetadata(deviceId);
113
+ return { device, appStarted: !!result.output, launchTimeMs: 1000 };
114
+ }
115
+ catch (e) {
116
+ const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
117
+ const device = await getIOSDeviceMetadata(deviceId);
118
+ return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
119
+ }
109
120
  }
110
121
  async terminateApp(bundleId, deviceId = "booted") {
111
122
  validateBundleId(bundleId);
112
- await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
113
- const device = await getIOSDeviceMetadata(deviceId);
114
- return { device, appTerminated: true };
123
+ try {
124
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
125
+ const device = await getIOSDeviceMetadata(deviceId);
126
+ return { device, appTerminated: true };
127
+ }
128
+ catch (e) {
129
+ const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
130
+ const device = await getIOSDeviceMetadata(deviceId);
131
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
132
+ }
115
133
  }
116
134
  async restartApp(bundleId, deviceId = "booted") {
117
135
  await this.terminateApp(bundleId, deviceId);
@@ -122,24 +140,30 @@ export class iOSManage {
122
140
  validateBundleId(bundleId);
123
141
  await this.terminateApp(bundleId, deviceId);
124
142
  const device = await getIOSDeviceMetadata(deviceId);
125
- const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
126
- const dataPath = containerResult.output.trim();
127
- if (!dataPath)
128
- throw new Error(`Could not find data container for ${bundleId}`);
129
143
  try {
130
- const libraryPath = `${dataPath}/Library`;
131
- const documentsPath = `${dataPath}/Documents`;
132
- const tmpPath = `${dataPath}/tmp`;
133
- await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
134
- await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
135
- await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
136
- await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
137
- await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
138
- await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
139
- return { device, dataCleared: true };
144
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
145
+ const dataPath = containerResult.output.trim();
146
+ if (!dataPath)
147
+ throw new Error(`Could not find data container for ${bundleId}`);
148
+ try {
149
+ const libraryPath = `${dataPath}/Library`;
150
+ const documentsPath = `${dataPath}/Documents`;
151
+ const tmpPath = `${dataPath}/tmp`;
152
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
153
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
154
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
155
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
156
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
157
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
158
+ return { device, dataCleared: true };
159
+ }
160
+ catch (e) {
161
+ throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
162
+ }
140
163
  }
141
164
  catch (e) {
142
- throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
165
+ const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
166
+ return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
143
167
  }
144
168
  }
145
169
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "child_process";
2
2
  import { promises as fs } from "fs";
3
- import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB, XCRUN } from "./utils.js";
3
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js";
4
4
  import { createWriteStream, promises as fsPromises } from 'fs';
5
5
  import path from 'path';
6
6
  import { parseLogLine } from '../android/utils.js';
@@ -9,6 +9,17 @@ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
9
9
  function parseIDBFrame(frame) {
10
10
  if (!frame)
11
11
  return [0, 0, 0, 0];
12
+ // Handle string frames like "{{0, 0}, {402, 874}}"
13
+ if (typeof frame === 'string') {
14
+ const nums = frame.match(/-?\d+(?:\.\d+)?/g);
15
+ if (!nums || nums.length < 4)
16
+ return [0, 0, 0, 0];
17
+ const x = Number(nums[0]);
18
+ const y = Number(nums[1]);
19
+ const w = Number(nums[2]);
20
+ const h = Number(nums[3]);
21
+ return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
22
+ }
12
23
  const x = Number(frame.x || 0);
13
24
  const y = Number(frame.y || 0);
14
25
  const w = Number(frame.width || frame.w || 0);
@@ -72,15 +83,6 @@ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
72
83
  }
73
84
  return currentIndex;
74
85
  }
75
- // Check if IDB is installed
76
- async function isIDBInstalled() {
77
- return new Promise((resolve) => {
78
- // Check if 'idb' is in path by trying to run it
79
- const child = spawn(IDB, ['--version']);
80
- child.on('error', () => resolve(false));
81
- child.on('close', (code) => resolve(code === 0));
82
- });
83
- }
84
86
  // iOS live log stream support (moved from ios/utils to observe)
85
87
  const iosActiveLogStreams = new Map();
86
88
  // Test helpers
@@ -161,12 +163,12 @@ export class iOSObserve {
161
163
  try {
162
164
  // Stabilization delay
163
165
  await delay(300 + (attempts * 100));
164
- const args = ['ui', 'describe', '--json'];
166
+ const args = ['ui', 'describe-all', '--json'];
165
167
  if (targetUdid) {
166
168
  args.push('--udid', targetUdid);
167
169
  }
168
170
  const output = await new Promise((resolve, reject) => {
169
- const child = spawn(IDB, args);
171
+ const child = spawn(getIdbCmd(), args);
170
172
  let stdout = '';
171
173
  let stderr = '';
172
174
  child.stdout.on('data', (data) => stdout += data.toString());
@@ -201,8 +203,15 @@ export class iOSObserve {
201
203
  }
202
204
  try {
203
205
  const elements = [];
204
- const root = jsonContent;
205
- traverseIDBNode(root, elements);
206
+ // idb describe-all returns either a root object or an array of root nodes
207
+ if (Array.isArray(jsonContent)) {
208
+ for (const node of jsonContent) {
209
+ traverseIDBNode(node, elements);
210
+ }
211
+ }
212
+ else {
213
+ traverseIDBNode(jsonContent, elements);
214
+ }
206
215
  // Infer resolution from root element if possible (usually the Window/Application frame)
207
216
  let width = 0;
208
217
  let height = 0;
@@ -240,7 +249,7 @@ export class iOSObserve {
240
249
  iosActiveLogStreams.delete(sessionId);
241
250
  }
242
251
  const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
243
- const proc = spawn(XCRUN, args);
252
+ const proc = spawn(getXcrunCmd(), args);
244
253
  // Prepare output file
245
254
  const tmpDir = process.env.TMPDIR || '/tmp';
246
255
  const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
package/dist/ios/utils.js CHANGED
@@ -1,8 +1,90 @@
1
- import { execFile, spawn } from "child_process";
1
+ import { execFile, spawn, execSync, spawnSync } from "child_process";
2
2
  import { promises as fsPromises } from 'fs';
3
3
  import path from 'path';
4
- export const XCRUN = process.env.XCRUN_PATH || "xcrun";
5
- export const IDB = "idb";
4
+ import { makeEnvSnapshot } from '../utils/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
+ }
6
88
  // Validate bundle ID to prevent any potential injection or invalid characters
7
89
  export function validateBundleId(bundleId) {
8
90
  if (!bundleId)
@@ -15,7 +97,7 @@ export function validateBundleId(bundleId) {
15
97
  export function execCommand(args, deviceId = "booted") {
16
98
  return new Promise((resolve, reject) => {
17
99
  // Use spawn for better stream control and consistency with Android implementation
18
- const child = spawn(XCRUN, args);
100
+ const child = spawn(getXcrunCmd(), args);
19
101
  let stdout = '';
20
102
  let stderr = '';
21
103
  if (child.stdout) {
@@ -28,10 +110,12 @@ export function execCommand(args, deviceId = "booted") {
28
110
  stderr += data.toString();
29
111
  });
30
112
  }
31
- const timeoutMs = args.includes('log') ? 10000 : 5000; // 10s for logs, 5s for others
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
32
116
  const timeout = setTimeout(() => {
33
117
  child.kill();
34
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`));
118
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
35
119
  }, timeoutMs);
36
120
  child.on('close', (code) => {
37
121
  clearTimeout(timeout);
@@ -48,6 +132,35 @@ export function execCommand(args, deviceId = "booted") {
48
132
  });
49
133
  });
50
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
+ }
51
164
  function parseRuntimeName(runtime) {
52
165
  // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
53
166
  try {
@@ -84,7 +197,7 @@ export async function findAppBundle(dir) {
84
197
  export async function getIOSDeviceMetadata(deviceId = "booted") {
85
198
  return new Promise((resolve) => {
86
199
  // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
87
- execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
200
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
88
201
  const fallback = {
89
202
  platform: "ios",
90
203
  id: deviceId,
@@ -126,7 +239,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
126
239
  }
127
240
  export async function listIOSDevices(appId) {
128
241
  return new Promise((resolve) => {
129
- execFile(XCRUN, ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
242
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
130
243
  if (err || !stdout)
131
244
  return resolve([]);
132
245
  try {