mobile-debug-mcp 0.11.0 → 0.12.1

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 (61) hide show
  1. package/README.md +10 -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/cli/idb/check-idb.js +84 -0
  7. package/dist/cli/idb/idb-helper.js +91 -0
  8. package/dist/cli/idb/install-idb.js +82 -0
  9. package/dist/cli/ios/preflight-ios.js +155 -0
  10. package/dist/cli/ios/run-ios-smoke.js +28 -0
  11. package/dist/cli/ios/run-ios-ui-tree-tap.js +29 -0
  12. package/dist/ios/interact.js +4 -8
  13. package/dist/ios/manage.js +177 -45
  14. package/dist/ios/observe.js +24 -15
  15. package/dist/ios/utils.js +121 -8
  16. package/dist/server.js +18 -0
  17. package/dist/utils/diagnostics.js +25 -0
  18. package/docs/CHANGELOG.md +19 -0
  19. package/eslint.config.js +21 -1
  20. package/package.json +10 -5
  21. package/src/android/diagnostics.ts +23 -0
  22. package/src/android/manage.ts +30 -8
  23. package/src/android/observe.ts +4 -4
  24. package/src/android/utils.ts +3 -3
  25. package/src/cli/idb/check-idb.ts +73 -0
  26. package/src/cli/idb/idb-helper.ts +75 -0
  27. package/src/cli/idb/install-idb.ts +90 -0
  28. package/src/cli/ios/preflight-ios.ts +144 -0
  29. package/src/cli/ios/run-ios-smoke.ts +34 -0
  30. package/src/cli/ios/run-ios-ui-tree-tap.ts +33 -0
  31. package/src/ios/interact.ts +4 -8
  32. package/src/ios/manage.ts +202 -64
  33. package/src/ios/observe.ts +24 -16
  34. package/src/ios/utils.ts +109 -8
  35. package/src/server.ts +19 -0
  36. package/src/types.ts +9 -0
  37. package/src/utils/diagnostics.ts +36 -0
  38. package/test/device/README.md +49 -0
  39. package/test/device/index.ts +27 -0
  40. package/test/device/manage/run-build-install-ios.ts +82 -0
  41. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  42. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  43. package/test/{integration → device/utils}/test-dist.ts +2 -2
  44. package/test/unit/index.ts +10 -6
  45. package/test/unit/{build.test.ts → manage/build.test.ts} +16 -17
  46. package/test/unit/{build_and_install.test.ts → manage/build_and_install.test.ts} +20 -18
  47. package/test/unit/manage/diagnostics.test.ts +85 -0
  48. package/test/unit/{install.test.ts → manage/install.test.ts} +26 -17
  49. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  50. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +2 -2
  51. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  52. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  53. package/tsconfig.json +2 -1
  54. package/test/integration/index.ts +0 -8
  55. package/test/integration/test-dist.mjs +0 -41
  56. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  57. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  58. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  59. /package/test/{integration → device/observe}/logstream-real.ts +0 -0
  60. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  61. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
@@ -0,0 +1,29 @@
1
+ import { iOSObserve } from '../../ios/observe.js';
2
+ import { iOSInteract } from '../../ios/interact.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();
@@ -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,43 +1,152 @@
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) {
7
7
  void _variant;
8
8
  try {
9
- const files = await fs.readdir(projectPath).catch(() => []);
10
- const workspace = files.find(f => f.endsWith('.xcworkspace'));
11
- const proj = files.find(f => f.endsWith('.xcodeproj'));
12
- if (!workspace && !proj)
9
+ // Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
10
+ async function findProject(root, maxDepth = 3) {
11
+ try {
12
+ const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
13
+ for (const e of ents) {
14
+ // .xcworkspace and .xcodeproj are directories on disk (bundles), not regular files
15
+ if (e.name.endsWith('.xcworkspace'))
16
+ return { dir: root, workspace: e.name };
17
+ if (e.name.endsWith('.xcodeproj'))
18
+ return { dir: root, proj: e.name };
19
+ }
20
+ }
21
+ catch { }
22
+ if (maxDepth <= 0)
23
+ return null;
24
+ try {
25
+ const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
26
+ for (const e of ents) {
27
+ if (e.isDirectory()) {
28
+ const candidate = await findProject(path.join(root, e.name), maxDepth - 1);
29
+ if (candidate)
30
+ return candidate;
31
+ }
32
+ }
33
+ }
34
+ catch { }
35
+ return null;
36
+ }
37
+ const projectInfo = await findProject(projectPath, 3);
38
+ if (!projectInfo)
13
39
  return { error: 'No Xcode project or workspace found' };
40
+ const projectRootDir = projectInfo.dir || projectPath;
41
+ const workspace = projectInfo.workspace;
42
+ const proj = projectInfo.proj;
43
+ // Determine destination: prefer explicit env var, otherwise use booted simulator UDID
44
+ let destinationUDID = process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || '';
45
+ if (!destinationUDID) {
46
+ try {
47
+ const meta = await getIOSDeviceMetadata('booted');
48
+ if (meta && meta.id)
49
+ destinationUDID = meta.id;
50
+ }
51
+ catch { }
52
+ }
14
53
  let buildArgs;
15
54
  if (workspace) {
16
- const workspacePath = path.join(projectPath, workspace);
55
+ const workspacePath = path.join(projectRootDir, workspace);
17
56
  const scheme = workspace.replace(/\.xcworkspace$/, '');
18
57
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
19
58
  }
20
59
  else {
21
- const projectPathFull = path.join(projectPath, proj);
60
+ const projectPathFull = path.join(projectRootDir, proj);
22
61
  const scheme = proj.replace(/\.xcodeproj$/, '');
23
62
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
24
63
  }
25
- await new Promise((resolve, reject) => {
26
- const proc = spawn('xcodebuild', buildArgs, { cwd: projectPath });
27
- let stderr = '';
28
- proc.stderr?.on('data', d => stderr += d.toString());
29
- proc.on('close', code => {
30
- if (code === 0)
31
- resolve();
32
- else
33
- reject(new Error(stderr || `xcodebuild failed with code ${code}`));
64
+ // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
65
+ if (destinationUDID) {
66
+ buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`);
67
+ }
68
+ // Add result bundle path for diagnostics
69
+ const resultsDir = path.join(projectPath, 'build-results');
70
+ // Remove any stale results to avoid xcodebuild complaining about existing result bundles
71
+ await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
72
+ await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
73
+ // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
74
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
75
+ const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
76
+ const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
77
+ const tries = MAX_RETRIES + 1;
78
+ let lastStdout = '';
79
+ let lastStderr = '';
80
+ let lastErr = null;
81
+ for (let attempt = 1; attempt <= tries; attempt++) {
82
+ // Run xcodebuild with a watchdog
83
+ const res = await new Promise((resolve) => {
84
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
85
+ let stdout = '';
86
+ let stderr = '';
87
+ proc.stdout?.on('data', d => stdout += d.toString());
88
+ proc.stderr?.on('data', d => stderr += d.toString());
89
+ let killed = false;
90
+ const to = setTimeout(() => {
91
+ killed = true;
92
+ try {
93
+ proc.kill('SIGKILL');
94
+ }
95
+ catch { }
96
+ }, XCODEBUILD_TIMEOUT);
97
+ proc.on('close', (code) => {
98
+ clearTimeout(to);
99
+ resolve({ code, stdout, stderr, killedByWatchdog: killed });
100
+ });
101
+ proc.on('error', (err) => {
102
+ clearTimeout(to);
103
+ resolve({ code: null, stdout, stderr: String(err), killedByWatchdog: killed });
104
+ });
34
105
  });
35
- proc.on('error', err => reject(err));
36
- });
106
+ lastStdout = res.stdout;
107
+ lastStderr = res.stderr;
108
+ if (res.code === 0) {
109
+ // success — clear any previous error and stop retrying
110
+ lastErr = null;
111
+ break;
112
+ }
113
+ // record the failure for reporting
114
+ lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`);
115
+ // write logs for diagnostics (helpful whether killed or not)
116
+ try {
117
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stdout.log`), res.stdout).catch(() => { });
118
+ await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stderr.log`), res.stderr).catch(() => { });
119
+ }
120
+ catch { }
121
+ // If killed by watchdog and there are remaining attempts, continue to retry
122
+ if (res.killedByWatchdog && attempt < tries) {
123
+ continue;
124
+ }
125
+ // no more retries or not a watchdog kill — break to report lastErr
126
+ if (attempt >= tries)
127
+ break;
128
+ }
129
+ if (lastErr) {
130
+ // Include diagnostics and result bundle path when available
131
+ return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}` };
132
+ }
133
+ // Try to locate built .app. First search project tree, then DerivedData if necessary
37
134
  const built = await findAppBundle(projectPath);
38
- if (!built)
39
- return { error: 'Could not find .app after build' };
40
- return { artifactPath: built };
135
+ if (built)
136
+ return { artifactPath: built };
137
+ // Fallback: search DerivedData for matching product
138
+ const dd = path.join(process.env.HOME || '', 'Library', 'Developer', 'Xcode', 'DerivedData');
139
+ try {
140
+ const entries = await fs.readdir(dd).catch(() => []);
141
+ for (const e of entries) {
142
+ const candidate = path.join(dd, e);
143
+ const found = await findAppBundle(candidate).catch(() => undefined);
144
+ if (found)
145
+ return { artifactPath: found };
146
+ }
147
+ }
148
+ catch { }
149
+ return { error: 'Could not find .app after build' };
41
150
  }
42
151
  catch (e) {
43
152
  return { error: e instanceof Error ? e.message : String(e) };
@@ -71,15 +180,18 @@ export class iOSManage {
71
180
  return { device, installed: true, output: res.output };
72
181
  }
73
182
  catch (e) {
183
+ // Gather diagnostics for simctl failure
184
+ const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId);
74
185
  try {
75
- const child = spawn(IDB, ['--version']);
186
+ const child = spawn(getIdbCmd(), ['--version']);
76
187
  const idbExists = await new Promise((resolve) => {
77
188
  child.on('error', () => resolve(false));
78
189
  child.on('close', (code) => resolve(code === 0));
79
190
  });
80
191
  if (idbExists) {
192
+ // attempt idb install via spawn but include diagnostics
81
193
  await new Promise((resolve, reject) => {
82
- const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
194
+ const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
83
195
  let stderr = '';
84
196
  proc.stderr.on('data', d => stderr += d.toString());
85
197
  proc.on('close', code => {
@@ -94,7 +206,7 @@ export class iOSManage {
94
206
  }
95
207
  }
96
208
  catch { }
97
- return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
209
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
98
210
  }
99
211
  }
100
212
  catch (e) {
@@ -103,15 +215,29 @@ export class iOSManage {
103
215
  }
104
216
  async startApp(bundleId, deviceId = "booted") {
105
217
  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 };
218
+ try {
219
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
220
+ const device = await getIOSDeviceMetadata(deviceId);
221
+ return { device, appStarted: !!result.output, launchTimeMs: 1000 };
222
+ }
223
+ catch (e) {
224
+ const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
225
+ const device = await getIOSDeviceMetadata(deviceId);
226
+ return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
227
+ }
109
228
  }
110
229
  async terminateApp(bundleId, deviceId = "booted") {
111
230
  validateBundleId(bundleId);
112
- await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
113
- const device = await getIOSDeviceMetadata(deviceId);
114
- return { device, appTerminated: true };
231
+ try {
232
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
233
+ const device = await getIOSDeviceMetadata(deviceId);
234
+ return { device, appTerminated: true };
235
+ }
236
+ catch (e) {
237
+ const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
238
+ const device = await getIOSDeviceMetadata(deviceId);
239
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
240
+ }
115
241
  }
116
242
  async restartApp(bundleId, deviceId = "booted") {
117
243
  await this.terminateApp(bundleId, deviceId);
@@ -122,24 +248,30 @@ export class iOSManage {
122
248
  validateBundleId(bundleId);
123
249
  await this.terminateApp(bundleId, deviceId);
124
250
  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
251
  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 };
252
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
253
+ const dataPath = containerResult.output.trim();
254
+ if (!dataPath)
255
+ throw new Error(`Could not find data container for ${bundleId}`);
256
+ try {
257
+ const libraryPath = `${dataPath}/Library`;
258
+ const documentsPath = `${dataPath}/Documents`;
259
+ const tmpPath = `${dataPath}/tmp`;
260
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
261
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
262
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
263
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
264
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
265
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
266
+ return { device, dataCleared: true };
267
+ }
268
+ catch (e) {
269
+ throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
270
+ }
140
271
  }
141
272
  catch (e) {
142
- throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
273
+ const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
274
+ return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
143
275
  }
144
276
  }
145
277
  }
@@ -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 {
package/dist/server.js CHANGED
@@ -126,6 +126,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
126
126
  required: ["appPath"]
127
127
  }
128
128
  },
129
+ {
130
+ name: "build_app",
131
+ description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
136
+ projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
137
+ variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
138
+ },
139
+ required: ["projectPath"]
140
+ }
141
+ },
129
142
  {
130
143
  name: "get_logs",
131
144
  description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
@@ -411,6 +424,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
411
424
  };
412
425
  return wrapResponse(response);
413
426
  }
427
+ if (name === "build_app") {
428
+ const { platform, projectPath, variant } = args;
429
+ const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant });
430
+ return wrapResponse(res);
431
+ }
414
432
  if (name === 'build_and_install') {
415
433
  const { platform, projectPath, deviceId, timeout } = args;
416
434
  const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout });
@@ -0,0 +1,25 @@
1
+ export function makeEnvSnapshot(keys) {
2
+ const snap = {};
3
+ for (const k of keys)
4
+ snap[k] = process.env[k];
5
+ return snap;
6
+ }
7
+ export function wrapExecResult(command, args, res) {
8
+ return {
9
+ exitCode: res.status,
10
+ stdout: res.stdout ? (typeof res.stdout === 'string' ? res.stdout : res.stdout.toString()) : '',
11
+ stderr: res.stderr ? (typeof res.stderr === 'string' ? res.stderr : res.stderr.toString()) : '',
12
+ envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
13
+ command,
14
+ args,
15
+ suggestedFixes: []
16
+ };
17
+ }
18
+ export class DiagnosticError extends Error {
19
+ runResult;
20
+ constructor(message, runResult) {
21
+ super(message);
22
+ this.name = 'DiagnosticError';
23
+ this.runResult = runResult;
24
+ }
25
+ }