mobile-debug-mcp 0.24.6 → 0.24.8

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 (48) hide show
  1. package/.github/workflows/ci.yml +1 -3
  2. package/README.md +12 -5
  3. package/dist/interact/index.js +89 -3
  4. package/dist/manage/android.js +9 -5
  5. package/dist/manage/index.js +37 -23
  6. package/dist/manage/ios.js +12 -15
  7. package/dist/server/common.js +46 -0
  8. package/dist/server/tool-handlers.js +120 -33
  9. package/dist/server-core.js +1 -1
  10. package/dist/utils/android/utils.js +17 -5
  11. package/dist/utils/cli/idb/check-idb.js +1 -1
  12. package/docs/CHANGELOG.md +18 -10
  13. package/eslint.config.js +2 -47
  14. package/package.json +7 -6
  15. package/src/interact/index.ts +112 -4
  16. package/src/manage/android.ts +22 -11
  17. package/src/manage/index.ts +37 -16
  18. package/src/manage/ios.ts +28 -15
  19. package/src/server/common.ts +50 -0
  20. package/src/server/tool-handlers.ts +136 -32
  21. package/src/server-core.ts +1 -1
  22. package/src/utils/android/utils.ts +18 -7
  23. package/src/utils/cli/idb/check-idb.ts +1 -1
  24. package/test/device/automated/observe/capture_screenshot.android.smoke.ts +1 -1
  25. package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +1 -1
  26. package/test/device/automated/observe/get_logs.android.smoke.ts +1 -1
  27. package/test/device/automated/observe/get_logs.ios.smoke.ts +1 -1
  28. package/test/device/automated/observe/get_ui_tree.android.smoke.ts +1 -1
  29. package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +1 -1
  30. package/test/device/manual/interact/app_lifecycle.manual.ts +3 -3
  31. package/test/device/manual/observe/capture_screenshot.manual.ts +2 -2
  32. package/test/device/manual/observe/get_logs.manual.ts +2 -2
  33. package/test/device/manual/observe/get_ui_tree.manual.ts +2 -2
  34. package/test/device/manual/observe/logstream.manual.ts +1 -1
  35. package/test/device/manual/observe/screen_fingerprint.manual.ts +2 -2
  36. package/test/unit/manage/scoped_env.test.ts +137 -0
  37. package/test/unit/observe/find_element.test.ts +64 -5
  38. package/test/unit/server/capture_screenshot.test.ts +17 -0
  39. package/test/unit/server/common.test.ts +18 -0
  40. package/test/unit/server/contract.test.ts +3 -0
  41. package/test/unit/server/get_logs.test.ts +17 -0
  42. package/test/unit/server/get_network_activity.test.ts +17 -0
  43. package/test/unit/server/get_ui_tree.test.ts +17 -0
  44. package/test/unit/server/response_shapes.test.ts +18 -0
  45. package/test/unit/server/start_log_stream.test.ts +37 -0
  46. package/.eslintignore +0 -5
  47. package/.eslintrc.cjs +0 -18
  48. package/eslint.config.cjs +0 -36
@@ -58,6 +58,4 @@ jobs:
58
58
  - name: Build and run Android integration tests
59
59
  env:
60
60
  ADB_TIMEOUT: 120000
61
- run: |
62
- npm run build
63
- node test/integration/run-install-android.js || true
61
+ run: npm run test:device
package/README.md CHANGED
@@ -3,11 +3,11 @@
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
5
  > **Support:**
6
- > * Android support
7
- > * iOS only tested on simulator
8
- > * KMP support
9
- > * Flutter iOS projects not fetching logs
10
- > * React native not tested
6
+ > * KMP
7
+ > * Android
8
+ > * iOS
9
+ > * Flutter - not tested
10
+ > * React native - not tested
11
11
 
12
12
  ## Requirements
13
13
 
@@ -52,8 +52,15 @@ Feature building:
52
52
 
53
53
  - `npm run test:unit` runs every automated unit test under `test/unit/...`
54
54
  - `npm run test:device` runs the automated device smoke checks under `test/device/automated/...`
55
+ - `npm run verify` runs the default maintainer verification sequence: lint, build, and unit tests
55
56
  - Manual and debug-oriented device scripts live under `test/device/manual/...` and are not part of the default test commands
56
57
 
58
+ ## Utility Scripts
59
+
60
+ - `npm run healthcheck` runs the `idb`/tooling healthcheck helper from `src/utils/cli/idb/check-idb.ts`
61
+ - `npm run install-idb` runs the guided `idb` installer helper from `src/utils/cli/idb/install-idb.ts`
62
+ - `npm run preflight-ios` runs the iOS preflight helper from `src/utils/cli/ios/preflight-ios.ts`
63
+
57
64
  ## Agent skills
58
65
 
59
66
  - `skills/mcp-builder/` contains reusable build/install guidance for agents
@@ -7,6 +7,14 @@ import { ToolsObserve } from '../observe/index.js';
7
7
  import { nextActionId } from '../server/common.js';
8
8
  export class ToolsInteract {
9
9
  static _maxResolvedUiElements = 256;
10
+ static _sliderSearchLookahead = 8;
11
+ static _sliderNegativeGapTolerancePx = 32;
12
+ static _sliderPositiveGapLimitPx = 640;
13
+ static _sliderTrackMinLengthPx = 220;
14
+ static _sliderTrackMaxThicknessPx = 180;
15
+ static _sliderTrackLengthRatio = 0.18;
16
+ static _sliderTrackThicknessRatio = 0.08;
17
+ static _sliderLabelWidthRatio = 1.5;
10
18
  static _resolvedUiElements = new Map();
11
19
  static _normalize(s) {
12
20
  if (s === null || s === undefined)
@@ -174,6 +182,63 @@ export class ToolsInteract {
174
182
  }
175
183
  return best;
176
184
  }
185
+ static _resolveNearbyActionableControl(elements, chosen, screen) {
186
+ if (!chosen)
187
+ return null;
188
+ const labelBounds = ToolsInteract._normalizeBounds(chosen.el.bounds);
189
+ if (!labelBounds)
190
+ return null;
191
+ const [labelLeft, labelTop, labelRight, labelBottom] = labelBounds;
192
+ const labelWidth = labelRight - labelLeft;
193
+ const labelHeight = labelBottom - labelTop;
194
+ const screenWidth = Number(screen?.width) > 0 ? Number(screen?.width) : 0;
195
+ const screenHeight = Number(screen?.height) > 0 ? Number(screen?.height) : 0;
196
+ const minTrackLengthPx = Math.max(ToolsInteract._sliderTrackMinLengthPx, screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackLengthRatio) : 0, screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackLengthRatio) : 0);
197
+ const maxTrackThicknessPx = Math.max(ToolsInteract._sliderTrackMaxThicknessPx, screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackThicknessRatio) : 0, screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackThicknessRatio) : 0);
198
+ let best = null;
199
+ let bestScore = Infinity;
200
+ for (let i = chosen.idx + 1; i < Math.min(elements.length, chosen.idx + ToolsInteract._sliderSearchLookahead); i++) {
201
+ const candidate = elements[i];
202
+ if (!candidate || !(candidate.clickable || candidate.focusable) || candidate.visible === false)
203
+ continue;
204
+ const candidateBounds = ToolsInteract._normalizeBounds(candidate.bounds);
205
+ if (!candidateBounds)
206
+ continue;
207
+ const [left, top, right] = candidateBounds;
208
+ const width = right - left;
209
+ const height = candidateBounds[3] - top;
210
+ const verticalGap = top - labelBottom;
211
+ if (verticalGap < -ToolsInteract._sliderNegativeGapTolerancePx || verticalGap > ToolsInteract._sliderPositiveGapLimitPx)
212
+ continue;
213
+ const horizontalOverlap = Math.min(labelRight, right) - Math.max(labelLeft, left);
214
+ if (horizontalOverlap < -ToolsInteract._sliderNegativeGapTolerancePx)
215
+ continue;
216
+ const candidateText = ToolsInteract._normalize(candidate.text ?? candidate.label ?? candidate.value ?? '');
217
+ const candidateContent = ToolsInteract._normalize(candidate.contentDescription ?? candidate.contentDesc ?? candidate.accessibilityLabel ?? '');
218
+ const candidateClass = ToolsInteract._normalize(candidate.type ?? candidate.class ?? '');
219
+ let score = verticalGap;
220
+ const horizontalTrackLike = width >= Math.max(minTrackLengthPx, Math.floor(labelWidth * ToolsInteract._sliderLabelWidthRatio)) &&
221
+ height <= maxTrackThicknessPx;
222
+ const verticalTrackLike = height >= Math.max(minTrackLengthPx, Math.floor(labelHeight * ToolsInteract._sliderLabelWidthRatio)) &&
223
+ width <= maxTrackThicknessPx;
224
+ const trackLike = /slider|seek|range/i.test(candidateClass) || horizontalTrackLike || verticalTrackLike;
225
+ if (!candidateText && !candidateContent)
226
+ score -= 18;
227
+ if (trackLike)
228
+ score -= 30;
229
+ if (/view|layout|group|frame/i.test(candidateClass))
230
+ score -= 10;
231
+ if (width > labelWidth * ToolsInteract._sliderLabelWidthRatio)
232
+ score -= 8;
233
+ if (candidateText || candidateContent)
234
+ score += 20;
235
+ if (score < bestScore) {
236
+ bestScore = score;
237
+ best = { el: candidate, idx: i, sliderLike: trackLike };
238
+ }
239
+ }
240
+ return best;
241
+ }
177
242
  static async getInteractionService(platform, deviceId) {
178
243
  const effectivePlatform = platform || 'android';
179
244
  const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
@@ -262,6 +327,7 @@ export class ToolsInteract {
262
327
  return { found: false, error: 'Empty query' };
263
328
  let best = null;
264
329
  let bestScore = 0;
330
+ let lastTree = null;
265
331
  const scoreElement = (el) => {
266
332
  if (!el || !el.visible)
267
333
  return 0;
@@ -305,6 +371,7 @@ export class ToolsInteract {
305
371
  while (Date.now() <= deadline) {
306
372
  try {
307
373
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
374
+ lastTree = tree;
308
375
  if (tree && Array.isArray(tree.elements)) {
309
376
  const elements = tree.elements;
310
377
  for (let i = 0; i < elements.length; i++) {
@@ -342,8 +409,8 @@ export class ToolsInteract {
342
409
  return { found: false, error: 'Element not found' };
343
410
  // If the best match is not interactable, try to resolve an actionable ancestor.
344
411
  try {
345
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
346
- const elements = (tree && Array.isArray(tree.elements)) ? tree.elements : [];
412
+ const elements = (lastTree && Array.isArray(lastTree.elements)) ? lastTree.elements : [];
413
+ const screen = lastTree?.resolution && typeof lastTree.resolution === 'object' ? lastTree.resolution : null;
347
414
  let chosen = best;
348
415
  const childBounds = Array.isArray(chosen?.bounds) ? chosen.bounds : null;
349
416
  // Strategy 1: if parentId references an index, climb that chain
@@ -412,6 +479,15 @@ export class ToolsInteract {
412
479
  // small score bump to reflect actionability
413
480
  bestScore = Math.min(1, bestScore + 0.02);
414
481
  }
482
+ if (best && !(best.clickable || best.focusable)) {
483
+ const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best, idx: best._index ?? elements.indexOf(best) }, screen);
484
+ if (nearbyActionable) {
485
+ best = nearbyActionable.el;
486
+ best._index = nearbyActionable.idx;
487
+ best._interactable = true;
488
+ best._sliderLike = nearbyActionable.sliderLike;
489
+ }
490
+ }
415
491
  }
416
492
  catch (e) {
417
493
  console.error('Error resolving ancestor:', e);
@@ -431,9 +507,19 @@ export class ToolsInteract {
431
507
  tapCoordinates,
432
508
  telemetry: {
433
509
  matchedIndex: best?._index ?? null,
434
- matchedInteractable: !!best?._interactable
510
+ matchedInteractable: !!best?._interactable,
511
+ sliderLike: !!best?._sliderLike
435
512
  }
436
513
  };
514
+ if (best?._sliderLike) {
515
+ const isVertical = !!boundsObj && (boundsObj.bottom - boundsObj.top) > (boundsObj.right - boundsObj.left);
516
+ const interactionHint = {
517
+ kind: 'slider',
518
+ axis: isVertical ? 'vertical' : 'horizontal',
519
+ trackBounds: boundsObj
520
+ };
521
+ outEl.interactionHint = interactionHint;
522
+ }
437
523
  const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
438
524
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
439
525
  }
@@ -2,7 +2,7 @@ import { promises as fs } from 'fs';
2
2
  import { spawn } from 'child_process';
3
3
  import path from 'path';
4
4
  import { existsSync } from 'fs';
5
- import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js';
5
+ import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk, prepareGradle } from '../utils/android/utils.js';
6
6
  import { execAdbWithDiagnostics } from '../utils/diagnostics.js';
7
7
  import { detectJavaHome } from '../utils/java.js';
8
8
  import { AndroidObserve } from '../observe/android.js';
@@ -10,11 +10,15 @@ export class AndroidManage {
10
10
  isTestOnlyInstallFailure(output) {
11
11
  return typeof output === 'string' && output.includes('INSTALL_FAILED_TEST_ONLY');
12
12
  }
13
- async build(projectPath, _variant) {
14
- void _variant;
13
+ async build(projectPath, optionsOrVariant) {
14
+ const options = typeof optionsOrVariant === 'string' ? { variant: optionsOrVariant } : (optionsOrVariant || {});
15
15
  try {
16
+ const env = {
17
+ ...(options.env || {}),
18
+ ...(options.variant ? { MCP_GRADLE_TASK: options.variant } : {})
19
+ };
16
20
  // Always use the shared prepareGradle utility for consistent env/setup
17
- const { execCmd, gradleArgs, spawnOpts } = await (await import('../utils/android/utils.js')).prepareGradle(projectPath);
21
+ const { execCmd, gradleArgs, spawnOpts } = await prepareGradle(projectPath, env);
18
22
  await new Promise((resolve, reject) => {
19
23
  const proc = spawn(execCmd, gradleArgs, spawnOpts);
20
24
  let stderr = '';
@@ -44,7 +48,7 @@ export class AndroidManage {
44
48
  const stat = await fs.stat(apkPath).catch(() => null);
45
49
  if (stat && stat.isDirectory()) {
46
50
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
47
- const env = Object.assign({}, process.env);
51
+ const env = { ...process.env };
48
52
  if (detectedJavaHome) {
49
53
  if (env.JAVA_HOME !== detectedJavaHome) {
50
54
  env.JAVA_HOME = detectedJavaHome;
@@ -70,31 +70,35 @@ export async function detectProjectPlatform(projectPath) {
70
70
  return 'unknown';
71
71
  }
72
72
  }
73
+ function mergeDefinedEnv(...parts) {
74
+ const merged = {};
75
+ for (const part of parts) {
76
+ if (!part)
77
+ continue;
78
+ for (const [key, value] of Object.entries(part)) {
79
+ if (typeof value === 'undefined')
80
+ continue;
81
+ merged[key] = value;
82
+ }
83
+ }
84
+ return merged;
85
+ }
73
86
  export class ToolsManage {
74
87
  static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }) {
75
88
  const android = new AndroidManage();
76
- // prepare gradle options via environment hints
77
- if (typeof maxWorkers === 'number')
78
- process.env.MCP_GRADLE_WORKERS = String(maxWorkers);
79
- if (typeof gradleCache === 'boolean')
80
- process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0';
81
- if (forceClean)
82
- process.env.MCP_FORCE_CLEAN_ANDROID = '1';
83
89
  const task = gradleTask || 'assembleDebug';
84
- const artifact = await android.build(projectPath, task);
85
- return artifact;
90
+ return await android.build(projectPath, {
91
+ variant: task,
92
+ env: mergeDefinedEnv({
93
+ MCP_GRADLE_TASK: task,
94
+ MCP_GRADLE_WORKERS: typeof maxWorkers === 'number' ? String(maxWorkers) : undefined,
95
+ MCP_GRADLE_CACHE: typeof gradleCache === 'boolean' ? (gradleCache ? '1' : '0') : undefined,
96
+ MCP_FORCE_CLEAN_ANDROID: forceClean ? '1' : undefined
97
+ })
98
+ });
86
99
  }
87
100
  static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
88
101
  const ios = new iOSManage();
89
- // Use provided options rather than env-only; still set env fallbacks for downstream tools
90
- if (derivedDataPath)
91
- process.env.MCP_DERIVED_DATA = derivedDataPath;
92
- if (typeof buildJobs === 'number')
93
- process.env.MCP_BUILD_JOBS = String(buildJobs);
94
- if (forceClean)
95
- process.env.MCP_FORCE_CLEAN_IOS = '1';
96
- if (destinationUDID)
97
- process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
98
102
  const opts = {};
99
103
  if (_workspace)
100
104
  opts.workspace = _workspace;
@@ -106,13 +110,23 @@ export class ToolsManage {
106
110
  opts.destinationUDID = destinationUDID;
107
111
  if (derivedDataPath)
108
112
  opts.derivedDataPath = derivedDataPath;
109
- if (forceClean)
113
+ if (typeof buildJobs === 'number')
114
+ opts.buildJobs = buildJobs;
115
+ if (typeof forceClean === 'boolean')
110
116
  opts.forceClean = forceClean;
111
117
  // prefer explicit xcodebuild path from env
112
118
  if (process.env.XCODEBUILD_PATH)
113
119
  opts.xcodeCmd = process.env.XCODEBUILD_PATH;
114
- const artifact = await ios.build(projectPath, opts);
115
- return artifact;
120
+ return await ios.build(projectPath, {
121
+ ...opts,
122
+ env: mergeDefinedEnv({
123
+ MCP_DERIVED_DATA: derivedDataPath,
124
+ MCP_XCODE_JOBS: typeof buildJobs === 'number' ? String(buildJobs) : undefined,
125
+ MCP_FORCE_CLEAN: typeof forceClean === 'boolean' ? (forceClean ? '1' : '0') : undefined,
126
+ MCP_XCODE_DESTINATION_UDID: destinationUDID,
127
+ XCODEBUILD_PATH: process.env.XCODEBUILD_PATH
128
+ })
129
+ });
116
130
  }
117
131
  static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
118
132
  // Prefer using flutter CLI when available; otherwise delegate to native subproject builders
@@ -199,12 +213,12 @@ export class ToolsManage {
199
213
  const chosen = platform || 'android';
200
214
  if (chosen === 'android') {
201
215
  const android = new AndroidManage();
202
- const artifact = await android.build(projectPath, variant);
216
+ const artifact = await android.build(projectPath, { variant });
203
217
  return artifact;
204
218
  }
205
219
  else {
206
220
  const ios = new iOSManage();
207
- const artifact = await ios.build(projectPath, variant);
221
+ const artifact = await ios.build(projectPath, { variant });
208
222
  return artifact;
209
223
  }
210
224
  }
@@ -6,11 +6,8 @@ import path from "path";
6
6
  export class iOSManage {
7
7
  async build(projectPath, optsOrVariant) {
8
8
  // Support legacy variant string as second arg
9
- let opts = {};
10
- if (typeof optsOrVariant === 'string')
11
- opts.variant = optsOrVariant;
12
- else
13
- opts = optsOrVariant || {};
9
+ const opts = typeof optsOrVariant === 'string' ? {} : (optsOrVariant || {});
10
+ const env = { ...process.env, ...(opts.env || {}) };
14
11
  try {
15
12
  // Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
16
13
  async function findProject(root, maxDepth = 4) {
@@ -66,7 +63,7 @@ export class iOSManage {
66
63
  proj = projectInfo.proj;
67
64
  }
68
65
  // Determine destination: prefer explicit option, then env var, otherwise use booted simulator UDID
69
- let destinationUDID = opts.destinationUDID || process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || '';
66
+ let destinationUDID = opts.destinationUDID || env.MCP_XCODE_DESTINATION_UDID || env.MCP_XCODE_DESTINATION || '';
70
67
  if (!destinationUDID) {
71
68
  try {
72
69
  const meta = await getIOSDeviceMetadata('booted');
@@ -76,7 +73,7 @@ export class iOSManage {
76
73
  catch { }
77
74
  }
78
75
  // Determine xcode command early so it can be used when detecting schemes
79
- const xcodeCmd = opts.xcodeCmd || process.env.XCODEBUILD_PATH || 'xcodebuild';
76
+ const xcodeCmd = opts.xcodeCmd || env.XCODEBUILD_PATH || 'xcodebuild';
80
77
  // Determine available schemes by querying xcodebuild -list rather than guessing
81
78
  async function detectScheme(xcodeCmdInner, workspacePath, projectPathFull, cwd) {
82
79
  try {
@@ -99,11 +96,11 @@ export class iOSManage {
99
96
  let buildArgs;
100
97
  let chosenScheme = opts.scheme || null;
101
98
  // Derived data and result bundle (agent-configurable)
102
- const derivedDataPath = opts.derivedDataPath || process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData');
99
+ const derivedDataPath = opts.derivedDataPath || env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData');
103
100
  // Use unique result bundle path by default to avoid collisions
104
- const resultBundlePath = process.env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`);
105
- const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4;
106
- const forceClean = opts.forceClean || process.env.MCP_FORCE_CLEAN === '1';
101
+ const resultBundlePath = env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`);
102
+ const xcodeJobs = typeof opts.buildJobs === 'number' ? opts.buildJobs : (parseInt(env.MCP_XCODE_JOBS || '', 10) || 4);
103
+ const forceClean = typeof opts.forceClean === 'boolean' ? opts.forceClean : env.MCP_FORCE_CLEAN === '1';
107
104
  // ensure result dirs exist
108
105
  await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => { });
109
106
  await fs.mkdir(derivedDataPath, { recursive: true }).catch(() => { });
@@ -144,8 +141,8 @@ export class iOSManage {
144
141
  // Remove any stale results to avoid xcodebuild complaining about existing result bundles
145
142
  await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
146
143
  await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
147
- const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
148
- const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
144
+ const XCODEBUILD_TIMEOUT = parseInt(env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
145
+ const MAX_RETRIES = parseInt(env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
149
146
  const tries = MAX_RETRIES + 1;
150
147
  let lastStdout = '';
151
148
  let lastStderr = '';
@@ -153,7 +150,7 @@ export class iOSManage {
153
150
  for (let attempt = 1; attempt <= tries; attempt++) {
154
151
  // Run xcodebuild with a watchdog
155
152
  const res = await new Promise((resolve) => {
156
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir });
153
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir, env });
157
154
  let stdout = '';
158
155
  let stderr = '';
159
156
  proc.stdout?.on('data', d => stdout += d.toString());
@@ -204,7 +201,7 @@ export class iOSManage {
204
201
  if (lastErr) {
205
202
  // Include diagnostics and result bundle path when available; provide structured info useful for agents
206
203
  const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`;
207
- const envSnapshot = { PATH: process.env.PATH };
204
+ const envSnapshot = { PATH: env.PATH };
208
205
  return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}`, diagnostics: { exitCode: lastErr.code || null, invokedCommand, cwd: projectRootDir, envSnapshot } };
209
206
  }
210
207
  // Try to locate built .app. First search project tree, then DerivedData if necessary
@@ -7,6 +7,52 @@ export function wrapResponse(data) {
7
7
  }]
8
8
  };
9
9
  }
10
+ export function getStringArg(args, key) {
11
+ const value = args[key];
12
+ return typeof value === 'string' ? value : undefined;
13
+ }
14
+ export function requireStringArg(args, key) {
15
+ const value = getStringArg(args, key);
16
+ if (value === undefined)
17
+ throw new Error(`Missing or invalid string argument: ${key}`);
18
+ return value;
19
+ }
20
+ export function getNumberArg(args, key) {
21
+ const value = args[key];
22
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
23
+ }
24
+ export function requireNumberArg(args, key) {
25
+ const value = getNumberArg(args, key);
26
+ if (value === undefined)
27
+ throw new Error(`Missing or invalid number argument: ${key}`);
28
+ return value;
29
+ }
30
+ export function getBooleanArg(args, key) {
31
+ const value = args[key];
32
+ return typeof value === 'boolean' ? value : undefined;
33
+ }
34
+ export function requireBooleanArg(args, key) {
35
+ const value = getBooleanArg(args, key);
36
+ if (value === undefined)
37
+ throw new Error(`Missing or invalid boolean argument: ${key}`);
38
+ return value;
39
+ }
40
+ export function getObjectArg(args, key) {
41
+ const value = args[key];
42
+ if (!value || typeof value !== 'object' || Array.isArray(value))
43
+ return undefined;
44
+ return value;
45
+ }
46
+ export function requireObjectArg(args, key) {
47
+ const value = getObjectArg(args, key);
48
+ if (value === undefined)
49
+ throw new Error(`Missing or invalid object argument: ${key}`);
50
+ return value;
51
+ }
52
+ export function getArrayArg(args, key) {
53
+ const value = args[key];
54
+ return Array.isArray(value) ? value : undefined;
55
+ }
10
56
  let actionSequence = 0;
11
57
  export function nextActionId(actionType, timestamp) {
12
58
  actionSequence += 1;