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
@@ -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
  import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
8
9
 
@@ -35,8 +36,8 @@ export class AndroidManage {
35
36
  const metadata = await getAndroidDeviceMetadata('', deviceId)
36
37
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
37
38
 
39
+ let apkToInstall: string = apkPath
38
40
  try {
39
- let apkToInstall = apkPath
40
41
  const stat = await fs.stat(apkPath).catch(() => null)
41
42
  if (stat && stat.isDirectory()) {
42
43
  const detectedJavaHome = await detectJavaHome().catch(() => undefined)
@@ -98,22 +99,38 @@ export class AndroidManage {
98
99
  try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
99
100
  return { device: deviceInfo, installed: true, output: pmOut }
100
101
  } catch (e) {
101
- return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) }
102
+ // gather diagnostics for attempted adb operations
103
+ const basename = path.basename(apkToInstall)
104
+ const remotePath = `/data/local/tmp/${basename}`
105
+ const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId)
106
+ const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId)
107
+ const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId)
108
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } }
102
109
  }
103
110
  }
104
111
 
105
112
  async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
106
113
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
107
114
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
108
- await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
109
- return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
115
+ try {
116
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
117
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
118
+ } catch (e:any) {
119
+ const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
120
+ return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
121
+ }
110
122
  }
111
123
 
112
124
  async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
113
125
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
114
126
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
115
- await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
116
- return { device: deviceInfo, appTerminated: true }
127
+ try {
128
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
129
+ return { device: deviceInfo, appTerminated: true }
130
+ } catch (e:any) {
131
+ const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
132
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
133
+ }
117
134
  }
118
135
 
119
136
  async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
@@ -129,7 +146,12 @@ export class AndroidManage {
129
146
  async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
130
147
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
131
148
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
132
- const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
133
- return { device: deviceInfo, dataCleared: output === 'Success' }
149
+ try {
150
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
151
+ return { device: deviceInfo, dataCleared: output === 'Success' }
152
+ } catch (e:any) {
153
+ const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId)
154
+ return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
155
+ }
134
156
  }
135
157
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "child_process"
2
2
  import { XMLParser } from "fast-xml-parser"
3
3
  import { GetLogsResponse, CaptureAndroidScreenResponse, GetUITreeResponse, GetCurrentScreenResponse, UIElement, DeviceInfo } from "../types.js"
4
- import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js"
4
+ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js"
5
5
  import { createWriteStream } from "fs"
6
6
  import { promises as fsPromises } from "fs"
7
7
  import path from "path"
@@ -81,7 +81,7 @@ export class AndroidObserve {
81
81
  elements
82
82
  };
83
83
  } catch (e) {
84
- const errorMessage = `Failed to get UI tree. ADB Path: '${ADB}'. Error: ${e instanceof Error ? e.message : String(e)}`;
84
+ const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
85
85
  console.error(errorMessage);
86
86
  return {
87
87
  device: deviceInfo,
@@ -136,7 +136,7 @@ export class AndroidObserve {
136
136
  const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
137
137
 
138
138
  // Using spawn for screencap as well to ensure consistent process handling
139
- const child = spawn(ADB, args)
139
+ const child = spawn(getAdbCmd(), args)
140
140
 
141
141
  const chunks: Buffer[] = []
142
142
  let stderr = ''
@@ -283,7 +283,7 @@ export class AndroidObserve {
283
283
  }
284
284
 
285
285
  const args = ['logcat', `--pid=${pid}`, filter]
286
- const proc = spawn(ADB, args)
286
+ const proc = spawn(getAdbCmd(), args)
287
287
 
288
288
  const tmpDir = process.env.TMPDIR || '/tmp'
289
289
  const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
@@ -4,7 +4,7 @@ import { promises as fsPromises, existsSync } from 'fs'
4
4
  import path from 'path'
5
5
  import { detectJavaHome } from '../utils/java.js'
6
6
 
7
- export const ADB = process.env.ADB_PATH || 'adb'
7
+ export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
8
8
 
9
9
  /**
10
10
  * Prepare Gradle execution options for building an Android project.
@@ -78,7 +78,7 @@ export function execAdb(args: string[], deviceId?: string, options: SpawnOptions
78
78
  const { timeout: customTimeout, ...spawnOptions } = options;
79
79
 
80
80
  // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
81
- const child = spawn(ADB, adbArgs, spawnOptions)
81
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
82
82
 
83
83
  let stdout = ''
84
84
  let stderr = ''
@@ -126,7 +126,7 @@ export function spawnAdb(args: string[], deviceId?: string, options: SpawnOption
126
126
  const adbArgs = getAdbArgs(args, deviceId)
127
127
  return new Promise((resolve, reject) => {
128
128
  const { timeout: customTimeout, ...spawnOptions } = options
129
- const child = spawn(ADB, adbArgs, spawnOptions)
129
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
130
130
 
131
131
  let stdout = ''
132
132
  let stderr = ''
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "child_process"
2
2
  import { WaitForElementResponse, TapResponse } from "../types.js"
3
- import { getIOSDeviceMetadata, IDB } from "./utils.js"
3
+ import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js"
4
4
  import { iOSObserve } from "./observe.js"
5
5
 
6
6
  export class iOSInteract {
@@ -39,12 +39,8 @@ export class iOSInteract {
39
39
  async tap(x: number, y: number, deviceId: string = "booted"): Promise<TapResponse> {
40
40
  const device = await getIOSDeviceMetadata(deviceId)
41
41
 
42
- // Check for idb
43
- const child = spawn(IDB, ['--version']);
44
- const idbExists = await new Promise<boolean>((resolve) => {
45
- child.on('error', () => resolve(false));
46
- child.on('close', (code) => resolve(code === 0));
47
- });
42
+ // Use shared helper to detect idb
43
+ const idbExists = await isIDBInstalled();
48
44
 
49
45
  if (!idbExists) {
50
46
  return {
@@ -64,7 +60,7 @@ export class iOSInteract {
64
60
  }
65
61
 
66
62
  await new Promise<void>((resolve, reject) => {
67
- const proc = spawn(IDB, args);
63
+ const proc = spawn(getIdbCmd(), args);
68
64
  let stderr = '';
69
65
  proc.stderr.on('data', d => stderr += d.toString());
70
66
  proc.on('close', code => {
package/src/ios/manage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "fs"
2
2
  import { spawn } from "child_process"
3
3
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
4
- import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB, findAppBundle } from "./utils.js"
4
+ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js"
5
5
  import path from "path"
6
6
 
7
7
  export class iOSManage {
@@ -25,7 +25,8 @@ export class iOSManage {
25
25
  }
26
26
 
27
27
  await new Promise<void>((resolve, reject) => {
28
- const proc = spawn('xcodebuild', buildArgs, { cwd: projectPath })
28
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
29
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath })
29
30
  let stderr = ''
30
31
  proc.stderr?.on('data', d => stderr += d.toString())
31
32
  proc.on('close', code => {
@@ -67,31 +68,34 @@ export class iOSManage {
67
68
  }
68
69
 
69
70
  try {
70
- const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId)
71
- return { device, installed: true, output: res.output }
72
- } catch (e) {
73
- try {
74
- const child = spawn(IDB, ['--version'])
75
- const idbExists = await new Promise<boolean>((resolve) => {
76
- child.on('error', () => resolve(false));
77
- child.on('close', (code) => resolve(code === 0));
78
- });
79
- if (idbExists) {
80
- await new Promise<void>((resolve, reject) => {
81
- const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
82
- let stderr = '';
83
- proc.stderr.on('data', d => stderr += d.toString());
84
- proc.on('close', code => {
85
- if (code === 0) resolve();
86
- else reject(new Error(stderr || `idb install failed with code ${code}`));
87
- });
88
- proc.on('error', err => reject(err));
71
+ const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId)
72
+ return { device, installed: true, output: res.output }
73
+ } catch (e) {
74
+ // Gather diagnostics for simctl failure
75
+ const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId)
76
+ try {
77
+ const child = spawn(getIdbCmd(), ['--version'])
78
+ const idbExists = await new Promise<boolean>((resolve) => {
79
+ child.on('error', () => resolve(false));
80
+ child.on('close', (code) => resolve(code === 0));
89
81
  });
90
- return { device, installed: true }
91
- }
92
- } catch {}
93
- return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
94
- }
82
+ if (idbExists) {
83
+ // attempt idb install via spawn but include diagnostics
84
+ await new Promise<void>((resolve, reject) => {
85
+ const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
86
+ let stderr = '';
87
+ proc.stderr.on('data', d => stderr += d.toString());
88
+ proc.on('close', code => {
89
+ if (code === 0) resolve();
90
+ else reject(new Error(stderr || `idb install failed with code ${code}`));
91
+ });
92
+ proc.on('error', err => reject(err));
93
+ });
94
+ return { device, installed: true }
95
+ }
96
+ } catch {}
97
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
98
+ }
95
99
  } catch (e) {
96
100
  return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
97
101
  }
@@ -99,16 +103,28 @@ export class iOSManage {
99
103
 
100
104
  async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
101
105
  validateBundleId(bundleId)
102
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
103
- const device = await getIOSDeviceMetadata(deviceId)
104
- return { device, appStarted: !!result.output, launchTimeMs: 1000 }
106
+ try {
107
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
108
+ const device = await getIOSDeviceMetadata(deviceId)
109
+ return { device, appStarted: !!result.output, launchTimeMs: 1000 }
110
+ } catch (e:any) {
111
+ const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
112
+ const device = await getIOSDeviceMetadata(deviceId)
113
+ return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
114
+ }
105
115
  }
106
116
 
107
117
  async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
108
118
  validateBundleId(bundleId)
109
- await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
110
- const device = await getIOSDeviceMetadata(deviceId)
111
- return { device, appTerminated: true }
119
+ try {
120
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
121
+ const device = await getIOSDeviceMetadata(deviceId)
122
+ return { device, appTerminated: true }
123
+ } catch (e:any) {
124
+ const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
125
+ const device = await getIOSDeviceMetadata(deviceId)
126
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
127
+ }
112
128
  }
113
129
 
114
130
  async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
@@ -121,23 +137,28 @@ export class iOSManage {
121
137
  validateBundleId(bundleId)
122
138
  await this.terminateApp(bundleId, deviceId)
123
139
  const device = await getIOSDeviceMetadata(deviceId)
124
- const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
125
- const dataPath = containerResult.output.trim()
126
- if (!dataPath) throw new Error(`Could not find data container for ${bundleId}`)
127
-
128
140
  try {
129
- const libraryPath = `${dataPath}/Library`
130
- const documentsPath = `${dataPath}/Documents`
131
- const tmpPath = `${dataPath}/tmp`
132
- await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => {})
133
- await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => {})
134
- await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
135
- await fs.mkdir(libraryPath, { recursive: true }).catch(() => {})
136
- await fs.mkdir(documentsPath, { recursive: true }).catch(() => {})
137
- await fs.mkdir(tmpPath, { recursive: true }).catch(() => {})
138
- return { device, dataCleared: true }
139
- } catch (e) {
140
- throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`)
141
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
142
+ const dataPath = containerResult.output.trim()
143
+ if (!dataPath) throw new Error(`Could not find data container for ${bundleId}`)
144
+
145
+ try {
146
+ const libraryPath = `${dataPath}/Library`
147
+ const documentsPath = `${dataPath}/Documents`
148
+ const tmpPath = `${dataPath}/tmp`
149
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => {})
150
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => {})
151
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
152
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => {})
153
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => {})
154
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => {})
155
+ return { device, dataCleared: true }
156
+ } catch (e) {
157
+ throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`)
158
+ }
159
+ } catch (e:any) {
160
+ const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
161
+ return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
141
162
  }
142
163
  }
143
164
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "child_process"
2
2
  import { promises as fs } from "fs"
3
3
  import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo } from "../types.js"
4
- import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB, XCRUN } from "./utils.js"
4
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js"
5
5
  import { createWriteStream, promises as fsPromises } from 'fs'
6
6
  import path from 'path'
7
7
  import { parseLogLine } from '../android/utils.js'
@@ -25,6 +25,17 @@ interface IDBElement {
25
25
 
26
26
  function parseIDBFrame(frame: any): [number, number, number, number] {
27
27
  if (!frame) return [0, 0, 0, 0];
28
+ // Handle string frames like "{{0, 0}, {402, 874}}"
29
+ if (typeof frame === 'string') {
30
+ const nums = frame.match(/-?\d+(?:\.\d+)?/g);
31
+ if (!nums || nums.length < 4) return [0, 0, 0, 0];
32
+ const x = Number(nums[0]);
33
+ const y = Number(nums[1]);
34
+ const w = Number(nums[2]);
35
+ const h = Number(nums[3]);
36
+ return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
37
+ }
38
+
28
39
  const x = Number(frame.x || 0);
29
40
  const y = Number(frame.y || 0);
30
41
  const w = Number(frame.width || frame.w || 0);
@@ -102,15 +113,7 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
102
113
  return currentIndex;
103
114
  }
104
115
 
105
- // Check if IDB is installed
106
- async function isIDBInstalled(): Promise<boolean> {
107
- return new Promise((resolve) => {
108
- // Check if 'idb' is in path by trying to run it
109
- const child = spawn(IDB, ['--version']);
110
- child.on('error', () => resolve(false));
111
- child.on('close', (code) => resolve(code === 0));
112
- });
113
- }
116
+
114
117
 
115
118
  // iOS live log stream support (moved from ios/utils to observe)
116
119
  const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
@@ -207,13 +210,13 @@ export class iOSObserve {
207
210
  // Stabilization delay
208
211
  await delay(300 + (attempts * 100));
209
212
 
210
- const args = ['ui', 'describe', '--json'];
213
+ const args = ['ui', 'describe-all', '--json'];
211
214
  if (targetUdid) {
212
215
  args.push('--udid', targetUdid);
213
216
  }
214
217
 
215
218
  const output = await new Promise<string>((resolve, reject) => {
216
- const child = spawn(IDB, args);
219
+ const child = spawn(getIdbCmd(), args);
217
220
  let stdout = '';
218
221
  let stderr = '';
219
222
 
@@ -252,9 +255,14 @@ export class iOSObserve {
252
255
 
253
256
  try {
254
257
  const elements: UIElement[] = [];
255
- const root = jsonContent;
256
-
257
- traverseIDBNode(root, elements);
258
+ // idb describe-all returns either a root object or an array of root nodes
259
+ if (Array.isArray(jsonContent)) {
260
+ for (const node of jsonContent) {
261
+ traverseIDBNode(node, elements);
262
+ }
263
+ } else {
264
+ traverseIDBNode(jsonContent, elements);
265
+ }
258
266
 
259
267
  // Infer resolution from root element if possible (usually the Window/Application frame)
260
268
  let width = 0;
@@ -293,7 +301,7 @@ export class iOSObserve {
293
301
  }
294
302
 
295
303
  const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate]
296
- const proc = spawn(XCRUN, args)
304
+ const proc = spawn(getXcrunCmd(), args)
297
305
 
298
306
  // Prepare output file
299
307
  const tmpDir = process.env.TMPDIR || '/tmp'
package/src/ios/utils.ts CHANGED
@@ -1,10 +1,76 @@
1
- import { execFile, spawn } from "child_process"
1
+ import { execFile, spawn, execSync, spawnSync } from "child_process"
2
2
  import { DeviceInfo } from "../types.js"
3
3
  import { promises as fsPromises } from 'fs'
4
4
  import path from 'path'
5
+ import { makeEnvSnapshot } from '../utils/diagnostics.js'
5
6
 
6
- export const XCRUN = process.env.XCRUN_PATH || "xcrun"
7
- export const IDB = "idb"
7
+ export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun' }
8
+
9
+ export function getConfiguredIdbPath(): string | undefined {
10
+ if (process.env.MCP_IDB_PATH) return process.env.MCP_IDB_PATH
11
+ if (process.env.IDB_PATH) return process.env.IDB_PATH
12
+ const cfgPaths = [
13
+ process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
14
+ `${process.cwd()}/mcp.config.json`
15
+ ]
16
+ try {
17
+ const fs = require('fs')
18
+ for (const p of cfgPaths) {
19
+ if (!p) 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) return json.idbPath
26
+ if (json.IDB_PATH) return json.IDB_PATH
27
+ }
28
+ }
29
+ } catch {}
30
+ }
31
+ } catch {}
32
+ return undefined
33
+ }
34
+
35
+ export function getIdbCmd() {
36
+ const cfg = getConfiguredIdbPath()
37
+ if (cfg) return cfg
38
+ if (process.env.IDB_PATH) return process.env.IDB_PATH
39
+ try {
40
+ const p = execSync('which idb', { stdio: ['ignore','pipe','ignore'] }).toString().trim()
41
+ if (p) return p
42
+ } catch {}
43
+ try {
44
+ const p2 = execSync('command -v idb', { stdio: ['ignore','pipe','ignore'] }).toString().trim()
45
+ if (p2) return p2
46
+ } catch {}
47
+ // check common user locations
48
+ const common = [
49
+ `${process.env.HOME}/Library/Python/3.9/bin/idb`,
50
+ `${process.env.HOME}/Library/Python/3.10/bin/idb`,
51
+ '/opt/homebrew/bin/idb',
52
+ '/usr/local/bin/idb',
53
+ ]
54
+ for (const c of common) {
55
+ try { execSync(`test -x ${c}`, { stdio: ['ignore','pipe','ignore'] }); return c } catch {}
56
+ }
57
+ return 'idb'
58
+ }
59
+
60
+ export async function isIDBInstalled(): Promise<boolean> {
61
+ const cmd = getIdbCmd()
62
+ try {
63
+ execSync(`command -v ${cmd}`, { stdio: ['ignore','pipe','ignore'] })
64
+ return true
65
+ } catch {
66
+ try {
67
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','ignore'], timeout: 2000 })
68
+ return true
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+ }
8
74
 
9
75
  export interface IOSResult {
10
76
  output: string
@@ -23,7 +89,7 @@ export function validateBundleId(bundleId: string) {
23
89
  export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
24
90
  return new Promise((resolve, reject) => {
25
91
  // Use spawn for better stream control and consistency with Android implementation
26
- const child = spawn(XCRUN, args)
92
+ const child = spawn(getXcrunCmd(), args)
27
93
 
28
94
  let stdout = ''
29
95
  let stderr = ''
@@ -40,10 +106,12 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
40
106
  })
41
107
  }
42
108
 
43
- const timeoutMs = args.includes('log') ? 10000 : 5000 // 10s for logs, 5s for others
109
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
110
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
111
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT // choose appropriate timeout
44
112
  const timeout = setTimeout(() => {
45
113
  child.kill()
46
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`))
114
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`))
47
115
  }, timeoutMs)
48
116
 
49
117
  child.on('close', (code) => {
@@ -62,6 +130,39 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
62
130
  })
63
131
  }
64
132
 
133
+ export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
134
+ // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
135
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
136
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
137
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT
138
+ const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs }) as any
139
+ const runResult = {
140
+ exitCode: typeof res.status === 'number' ? res.status : null,
141
+ stdout: res.stdout || '',
142
+ stderr: res.stderr || '',
143
+ envSnapshot: makeEnvSnapshot(['PATH','IDB_PATH','JAVA_HOME','HOME']),
144
+ command: getXcrunCmd(),
145
+ args,
146
+ deviceId
147
+ }
148
+
149
+ if (res.status !== 0) {
150
+ // include suggested fixes for common errors
151
+ const suggested: string[] = []
152
+ if ((runResult.stderr || '').includes('xcodebuild: error')) {
153
+ suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.')
154
+ }
155
+ if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
156
+ suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.')
157
+ }
158
+
159
+ // Return diagnostics object
160
+ return { runResult: { ...runResult, suggestedFixes: suggested } }
161
+ }
162
+
163
+ return { runResult: { ...runResult, suggestedFixes: [] } }
164
+ }
165
+
65
166
  function parseRuntimeName(runtime: string): string {
66
167
  // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
67
168
  try {
@@ -99,7 +200,7 @@ export async function findAppBundle(dir: string): Promise<string | undefined> {
99
200
  export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
100
201
  return new Promise((resolve) => {
101
202
  // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
102
- execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
203
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
103
204
  const fallback: DeviceInfo = {
104
205
  platform: "ios",
105
206
  id: deviceId,
@@ -145,7 +246,7 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
145
246
 
146
247
  export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
147
248
  return new Promise((resolve) => {
148
- execFile(XCRUN, ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
249
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
149
250
  if (err || !stdout) return resolve([])
150
251
  try {
151
252
  const data = JSON.parse(stdout)
package/src/server.ts CHANGED
@@ -145,6 +145,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
145
145
  required: ["appPath"]
146
146
  }
147
147
  },
148
+ {
149
+ name: "build_app",
150
+ description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
155
+ projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
156
+ variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
157
+ },
158
+ required: ["projectPath"]
159
+ }
160
+ },
148
161
  {
149
162
  name: "get_logs",
150
163
  description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
@@ -438,6 +451,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
438
451
  return wrapResponse(response)
439
452
  }
440
453
 
454
+ if (name === "build_app") {
455
+ const { platform, projectPath, variant } = args as any
456
+ const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant })
457
+ return wrapResponse(res)
458
+ }
459
+
441
460
  if (name === 'build_and_install') {
442
461
  const { platform, projectPath, deviceId, timeout } = args as any
443
462
  const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout })
package/src/types.ts CHANGED
@@ -10,22 +10,30 @@ export interface StartAppResponse {
10
10
  device: DeviceInfo;
11
11
  appStarted: boolean;
12
12
  launchTimeMs: number;
13
+ error?: string;
14
+ diagnostics?: any;
13
15
  }
14
16
 
15
17
  export interface TerminateAppResponse {
16
18
  device: DeviceInfo;
17
19
  appTerminated: boolean;
20
+ error?: string;
21
+ diagnostics?: any;
18
22
  }
19
23
 
20
24
  export interface RestartAppResponse {
21
25
  device: DeviceInfo;
22
26
  appRestarted: boolean;
23
27
  launchTimeMs: number;
28
+ error?: string;
29
+ diagnostics?: any;
24
30
  }
25
31
 
26
32
  export interface ResetAppDataResponse {
27
33
  device: DeviceInfo;
28
34
  dataCleared: boolean;
35
+ error?: string;
36
+ diagnostics?: any;
29
37
  }
30
38
 
31
39
  export interface GetLogsResponse {
@@ -133,4 +141,5 @@ export interface InstallAppResponse {
133
141
  installed: boolean;
134
142
  output?: string;
135
143
  error?: string;
144
+ diagnostics?: any;
136
145
  }