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
@@ -36,6 +36,7 @@ interface UiElement {
36
36
  parentId?: number | string | null
37
37
  _index?: number
38
38
  _interactable?: boolean
39
+ _sliderLike?: boolean
39
40
  }
40
41
 
41
42
  interface ResolvedUiElementContext {
@@ -46,9 +47,22 @@ interface ResolvedUiElementContext {
46
47
  index: number
47
48
  }
48
49
 
50
+ interface UiResolution {
51
+ width?: number
52
+ height?: number
53
+ }
54
+
49
55
 
50
56
  export class ToolsInteract {
51
57
  private static readonly _maxResolvedUiElements = 256
58
+ private static readonly _sliderSearchLookahead = 8
59
+ private static readonly _sliderNegativeGapTolerancePx = 32
60
+ private static readonly _sliderPositiveGapLimitPx = 640
61
+ private static readonly _sliderTrackMinLengthPx = 220
62
+ private static readonly _sliderTrackMaxThicknessPx = 180
63
+ private static readonly _sliderTrackLengthRatio = 0.18
64
+ private static readonly _sliderTrackThicknessRatio = 0.08
65
+ private static readonly _sliderLabelWidthRatio = 1.5
52
66
  private static _resolvedUiElements = new Map<string, ResolvedUiElementContext>()
53
67
 
54
68
  private static _normalize(s: any): string {
@@ -240,6 +254,78 @@ export class ToolsInteract {
240
254
  return best
241
255
  }
242
256
 
257
+ private static _resolveNearbyActionableControl(
258
+ elements: UiElement[],
259
+ chosen: { el: UiElement, idx: number } | null,
260
+ screen?: UiResolution | null
261
+ ): { el: UiElement, idx: number, sliderLike?: boolean } | null {
262
+ if (!chosen) return null
263
+
264
+ const labelBounds = ToolsInteract._normalizeBounds(chosen.el.bounds)
265
+ if (!labelBounds) return null
266
+
267
+ const [labelLeft, labelTop, labelRight, labelBottom] = labelBounds
268
+ const labelWidth = labelRight - labelLeft
269
+ const labelHeight = labelBottom - labelTop
270
+ const screenWidth = Number(screen?.width) > 0 ? Number(screen?.width) : 0
271
+ const screenHeight = Number(screen?.height) > 0 ? Number(screen?.height) : 0
272
+ const minTrackLengthPx = Math.max(
273
+ ToolsInteract._sliderTrackMinLengthPx,
274
+ screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackLengthRatio) : 0,
275
+ screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackLengthRatio) : 0
276
+ )
277
+ const maxTrackThicknessPx = Math.max(
278
+ ToolsInteract._sliderTrackMaxThicknessPx,
279
+ screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackThicknessRatio) : 0,
280
+ screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackThicknessRatio) : 0
281
+ )
282
+
283
+ let best: { el: UiElement, idx: number, sliderLike?: boolean } | null = null
284
+ let bestScore = Infinity
285
+
286
+ for (let i = chosen.idx + 1; i < Math.min(elements.length, chosen.idx + ToolsInteract._sliderSearchLookahead); i++) {
287
+ const candidate = elements[i]
288
+ if (!candidate || !(candidate.clickable || candidate.focusable) || candidate.visible === false) continue
289
+
290
+ const candidateBounds = ToolsInteract._normalizeBounds(candidate.bounds)
291
+ if (!candidateBounds) continue
292
+
293
+ const [left, top, right] = candidateBounds
294
+ const width = right - left
295
+ const height = candidateBounds[3] - top
296
+ const verticalGap = top - labelBottom
297
+ if (verticalGap < -ToolsInteract._sliderNegativeGapTolerancePx || verticalGap > ToolsInteract._sliderPositiveGapLimitPx) continue
298
+
299
+ const horizontalOverlap = Math.min(labelRight, right) - Math.max(labelLeft, left)
300
+ if (horizontalOverlap < -ToolsInteract._sliderNegativeGapTolerancePx) continue
301
+
302
+ const candidateText = ToolsInteract._normalize(candidate.text ?? candidate.label ?? candidate.value ?? '')
303
+ const candidateContent = ToolsInteract._normalize(candidate.contentDescription ?? candidate.contentDesc ?? candidate.accessibilityLabel ?? '')
304
+ const candidateClass = ToolsInteract._normalize(candidate.type ?? candidate.class ?? '')
305
+
306
+ let score = verticalGap
307
+ const horizontalTrackLike =
308
+ width >= Math.max(minTrackLengthPx, Math.floor(labelWidth * ToolsInteract._sliderLabelWidthRatio)) &&
309
+ height <= maxTrackThicknessPx
310
+ const verticalTrackLike =
311
+ height >= Math.max(minTrackLengthPx, Math.floor(labelHeight * ToolsInteract._sliderLabelWidthRatio)) &&
312
+ width <= maxTrackThicknessPx
313
+ const trackLike = /slider|seek|range/i.test(candidateClass) || horizontalTrackLike || verticalTrackLike
314
+ if (!candidateText && !candidateContent) score -= 18
315
+ if (trackLike) score -= 30
316
+ if (/view|layout|group|frame/i.test(candidateClass)) score -= 10
317
+ if (width > labelWidth * ToolsInteract._sliderLabelWidthRatio) score -= 8
318
+ if (candidateText || candidateContent) score += 20
319
+
320
+ if (score < bestScore) {
321
+ bestScore = score
322
+ best = { el: candidate, idx: i, sliderLike: trackLike }
323
+ }
324
+ }
325
+
326
+ return best
327
+ }
328
+
243
329
 
244
330
  private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
245
331
  const effectivePlatform = platform || 'android'
@@ -347,6 +433,7 @@ export class ToolsInteract {
347
433
 
348
434
  let best: UiElement | null = null
349
435
  let bestScore = 0
436
+ let lastTree: any = null
350
437
 
351
438
  const scoreElement = (el: UiElement | null) => {
352
439
  if (!el || !el.visible) return 0
@@ -380,7 +467,8 @@ export class ToolsInteract {
380
467
 
381
468
  while (Date.now() <= deadline) {
382
469
  try {
383
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId })
470
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId })
471
+ lastTree = tree
384
472
  if (tree && Array.isArray((tree as any).elements)) {
385
473
  const elements = ((tree as any).elements as UiElement[])
386
474
  for (let i = 0; i < elements.length; i++) {
@@ -407,8 +495,8 @@ export class ToolsInteract {
407
495
 
408
496
  // If the best match is not interactable, try to resolve an actionable ancestor.
409
497
  try {
410
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
411
- const elements = (tree && Array.isArray(tree.elements)) ? (tree.elements as UiElement[]) : []
498
+ const elements = (lastTree && Array.isArray(lastTree.elements)) ? (lastTree.elements as UiElement[]) : []
499
+ const screen = lastTree?.resolution && typeof lastTree.resolution === 'object' ? lastTree.resolution as UiResolution : null
412
500
  let chosen = best as any
413
501
  const childBounds = Array.isArray(chosen?.bounds) ? chosen.bounds : null
414
502
 
@@ -465,6 +553,16 @@ export class ToolsInteract {
465
553
  // small score bump to reflect actionability
466
554
  bestScore = Math.min(1, bestScore + 0.02)
467
555
  }
556
+
557
+ if (best && !(best.clickable || best.focusable)) {
558
+ const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best, idx: best._index ?? elements.indexOf(best) }, screen)
559
+ if (nearbyActionable) {
560
+ best = nearbyActionable.el
561
+ best._index = nearbyActionable.idx
562
+ best._interactable = true
563
+ best._sliderLike = nearbyActionable.sliderLike
564
+ }
565
+ }
468
566
  } catch (e) { console.error('Error resolving ancestor:', e) }
469
567
 
470
568
  if (!best) return { found: false, error: 'Element not found' }
@@ -483,8 +581,18 @@ export class ToolsInteract {
483
581
  tapCoordinates,
484
582
  telemetry: {
485
583
  matchedIndex: best?._index ?? null,
486
- matchedInteractable: !!best?._interactable
584
+ matchedInteractable: !!best?._interactable,
585
+ sliderLike: !!best?._sliderLike
586
+ }
587
+ }
588
+ if (best?._sliderLike) {
589
+ const isVertical = !!boundsObj && (boundsObj.bottom - boundsObj.top) > (boundsObj.right - boundsObj.left)
590
+ const interactionHint = {
591
+ kind: 'slider',
592
+ axis: isVertical ? 'vertical' : 'horizontal',
593
+ trackBounds: boundsObj
487
594
  }
595
+ ;(outEl as any).interactionHint = interactionHint
488
596
  }
489
597
  const scoreVal = Math.min(1, Number(bestScore.toFixed(3)))
490
598
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
@@ -2,22 +2,33 @@ 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'
9
9
  import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
10
10
 
11
+ type BuildEnv = Record<string, string | undefined>
12
+
13
+ export interface AndroidBuildOptions {
14
+ variant?: string
15
+ env?: BuildEnv
16
+ }
17
+
11
18
  export class AndroidManage {
12
19
  private isTestOnlyInstallFailure(output: string | undefined): boolean {
13
20
  return typeof output === 'string' && output.includes('INSTALL_FAILED_TEST_ONLY')
14
21
  }
15
22
 
16
- async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
17
- void _variant
23
+ async build(projectPath: string, optionsOrVariant?: string | AndroidBuildOptions): Promise<{ artifactPath: string, output?: string } | { error: string }> {
24
+ const options: AndroidBuildOptions = typeof optionsOrVariant === 'string' ? { variant: optionsOrVariant } : (optionsOrVariant || {})
18
25
  try {
26
+ const env = {
27
+ ...(options.env || {}),
28
+ ...(options.variant ? { MCP_GRADLE_TASK: options.variant } : {})
29
+ }
19
30
  // Always use the shared prepareGradle utility for consistent env/setup
20
- const { execCmd, gradleArgs, spawnOpts } = await (await import('../utils/android/utils.js')).prepareGradle(projectPath)
31
+ const { execCmd, gradleArgs, spawnOpts } = await prepareGradle(projectPath, env)
21
32
  await new Promise<void>((resolve, reject) => {
22
33
  const proc = spawn(execCmd, gradleArgs, spawnOpts)
23
34
  let stderr = ''
@@ -43,13 +54,13 @@ export class AndroidManage {
43
54
 
44
55
  let apkToInstall: string = apkPath
45
56
  try {
46
- const stat = await fs.stat(apkPath).catch(() => null)
47
- if (stat && stat.isDirectory()) {
48
- const detectedJavaHome = await detectJavaHome().catch(() => undefined)
49
- const env = Object.assign({}, process.env)
50
- if (detectedJavaHome) {
51
- if (env.JAVA_HOME !== detectedJavaHome) {
52
- env.JAVA_HOME = detectedJavaHome
57
+ const stat = await fs.stat(apkPath).catch(() => null)
58
+ if (stat && stat.isDirectory()) {
59
+ const detectedJavaHome = await detectJavaHome().catch(() => undefined)
60
+ const env = { ...process.env }
61
+ if (detectedJavaHome) {
62
+ if (env.JAVA_HOME !== detectedJavaHome) {
63
+ env.JAVA_HOME = detectedJavaHome
53
64
  env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
54
65
  console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome)
55
66
  }
@@ -60,25 +60,37 @@ export async function detectProjectPlatform(projectPath: string): Promise<'ios'|
60
60
  }
61
61
  }
62
62
 
63
+ type BuildEnv = Record<string, string | undefined>
64
+
65
+ function mergeDefinedEnv(...parts: Array<BuildEnv | undefined>): BuildEnv {
66
+ const merged: BuildEnv = {}
67
+ for (const part of parts) {
68
+ if (!part) continue
69
+ for (const [key, value] of Object.entries(part)) {
70
+ if (typeof value === 'undefined') continue
71
+ merged[key] = value
72
+ }
73
+ }
74
+ return merged
75
+ }
76
+
63
77
  export class ToolsManage {
64
78
  static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }: { projectPath: string, gradleTask?: string, maxWorkers?: number, gradleCache?: boolean, forceClean?: boolean }) {
65
79
  const android = new AndroidManage()
66
- // prepare gradle options via environment hints
67
- if (typeof maxWorkers === 'number') process.env.MCP_GRADLE_WORKERS = String(maxWorkers)
68
- if (typeof gradleCache === 'boolean') process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0'
69
- if (forceClean) process.env.MCP_FORCE_CLEAN_ANDROID = '1'
70
80
  const task = gradleTask || 'assembleDebug'
71
- const artifact = await (android as any).build(projectPath, task)
72
- return artifact
81
+ return await (android as any).build(projectPath, {
82
+ variant: task,
83
+ env: mergeDefinedEnv({
84
+ MCP_GRADLE_TASK: task,
85
+ MCP_GRADLE_WORKERS: typeof maxWorkers === 'number' ? String(maxWorkers) : undefined,
86
+ MCP_GRADLE_CACHE: typeof gradleCache === 'boolean' ? (gradleCache ? '1' : '0') : undefined,
87
+ MCP_FORCE_CLEAN_ANDROID: forceClean ? '1' : undefined
88
+ })
89
+ })
73
90
  }
74
91
 
75
92
  static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }: { projectPath: string, workspace?: string, project?: string, scheme?: string, destinationUDID?: string, derivedDataPath?: string, buildJobs?: number, forceClean?: boolean }) {
76
93
  const ios = new iOSManage()
77
- // Use provided options rather than env-only; still set env fallbacks for downstream tools
78
- if (derivedDataPath) process.env.MCP_DERIVED_DATA = derivedDataPath
79
- if (typeof buildJobs === 'number') process.env.MCP_BUILD_JOBS = String(buildJobs)
80
- if (forceClean) process.env.MCP_FORCE_CLEAN_IOS = '1'
81
- if (destinationUDID) process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID
82
94
 
83
95
  const opts: any = {}
84
96
  if (_workspace) opts.workspace = _workspace
@@ -86,12 +98,21 @@ export class ToolsManage {
86
98
  if (_scheme) opts.scheme = _scheme
87
99
  if (destinationUDID) opts.destinationUDID = destinationUDID
88
100
  if (derivedDataPath) opts.derivedDataPath = derivedDataPath
89
- if (forceClean) opts.forceClean = forceClean
101
+ if (typeof buildJobs === 'number') opts.buildJobs = buildJobs
102
+ if (typeof forceClean === 'boolean') opts.forceClean = forceClean
90
103
  // prefer explicit xcodebuild path from env
91
104
  if (process.env.XCODEBUILD_PATH) opts.xcodeCmd = process.env.XCODEBUILD_PATH
92
105
 
93
- const artifact = await (ios as any).build(projectPath, opts)
94
- return artifact
106
+ return await (ios as any).build(projectPath, {
107
+ ...opts,
108
+ env: mergeDefinedEnv({
109
+ MCP_DERIVED_DATA: derivedDataPath,
110
+ MCP_XCODE_JOBS: typeof buildJobs === 'number' ? String(buildJobs) : undefined,
111
+ MCP_FORCE_CLEAN: typeof forceClean === 'boolean' ? (forceClean ? '1' : '0') : undefined,
112
+ MCP_XCODE_DESTINATION_UDID: destinationUDID,
113
+ XCODEBUILD_PATH: process.env.XCODEBUILD_PATH
114
+ })
115
+ })
95
116
  }
96
117
 
97
118
  static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }: { projectPath: string, platform?: 'android'|'ios', buildMode?: 'debug'|'release'|'profile', maxWorkers?: number, forceClean?: boolean }) {
@@ -178,11 +199,11 @@ export class ToolsManage {
178
199
  const chosen = platform || 'android'
179
200
  if (chosen === 'android') {
180
201
  const android = new AndroidManage()
181
- const artifact = await (android as any).build(projectPath, variant)
202
+ const artifact = await (android as any).build(projectPath, { variant })
182
203
  return artifact
183
204
  } else {
184
205
  const ios = new iOSManage()
185
- const artifact = await (ios as any).build(projectPath, variant)
206
+ const artifact = await (ios as any).build(projectPath, { variant })
186
207
  return artifact
187
208
  }
188
209
  }
package/src/manage/ios.ts CHANGED
@@ -5,12 +5,25 @@ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validate
5
5
  import { iOSObserve } from "../observe/ios.js"
6
6
  import path from "path"
7
7
 
8
+ type BuildEnv = Record<string, string | undefined>
9
+
10
+ export interface IOSBuildOptions {
11
+ workspace?: string
12
+ project?: string
13
+ scheme?: string
14
+ destinationUDID?: string
15
+ derivedDataPath?: string
16
+ buildJobs?: number
17
+ forceClean?: boolean
18
+ xcodeCmd?: string
19
+ env?: BuildEnv
20
+ }
21
+
8
22
  export class iOSManage {
9
- async build(projectPath: string, optsOrVariant?: string | { workspace?: string, project?: string, scheme?: string, destinationUDID?: string, derivedDataPath?: string, forceClean?: boolean, xcodeCmd?: string }): Promise<{ artifactPath: string, output?: string } | { error: string, diagnostics?: any }> {
23
+ async build(projectPath: string, optsOrVariant?: string | IOSBuildOptions): Promise<{ artifactPath: string, output?: string } | { error: string, diagnostics?: any }> {
10
24
  // Support legacy variant string as second arg
11
- let opts: any = {}
12
- if (typeof optsOrVariant === 'string') opts.variant = optsOrVariant
13
- else opts = optsOrVariant || {}
25
+ const opts: IOSBuildOptions = typeof optsOrVariant === 'string' ? {} : (optsOrVariant || {})
26
+ const env = { ...process.env, ...(opts.env || {}) }
14
27
 
15
28
  try {
16
29
  // Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
@@ -65,7 +78,7 @@ export class iOSManage {
65
78
  }
66
79
 
67
80
  // Determine destination: prefer explicit option, then env var, otherwise use booted simulator UDID
68
- let destinationUDID = opts.destinationUDID || process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || ''
81
+ let destinationUDID = opts.destinationUDID || env.MCP_XCODE_DESTINATION_UDID || env.MCP_XCODE_DESTINATION || ''
69
82
  if (!destinationUDID) {
70
83
  try {
71
84
  const meta = await getIOSDeviceMetadata('booted')
@@ -74,7 +87,7 @@ export class iOSManage {
74
87
  }
75
88
 
76
89
  // Determine xcode command early so it can be used when detecting schemes
77
- const xcodeCmd = opts.xcodeCmd || process.env.XCODEBUILD_PATH || 'xcodebuild'
90
+ const xcodeCmd = opts.xcodeCmd || env.XCODEBUILD_PATH || 'xcodebuild'
78
91
 
79
92
  // Determine available schemes by querying xcodebuild -list rather than guessing
80
93
  async function detectScheme(xcodeCmdInner: string, workspacePath?: string, projectPathFull?: string, cwd?: string): Promise<string | null> {
@@ -98,11 +111,11 @@ export class iOSManage {
98
111
  let chosenScheme: string | null = opts.scheme || null
99
112
 
100
113
  // Derived data and result bundle (agent-configurable)
101
- const derivedDataPath = opts.derivedDataPath || process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData')
114
+ const derivedDataPath = opts.derivedDataPath || env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData')
102
115
  // Use unique result bundle path by default to avoid collisions
103
- const resultBundlePath = process.env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`)
104
- const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4
105
- const forceClean = opts.forceClean || process.env.MCP_FORCE_CLEAN === '1'
116
+ const resultBundlePath = env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`)
117
+ const xcodeJobs = typeof opts.buildJobs === 'number' ? opts.buildJobs : (parseInt(env.MCP_XCODE_JOBS || '', 10) || 4)
118
+ const forceClean = typeof opts.forceClean === 'boolean' ? opts.forceClean : env.MCP_FORCE_CLEAN === '1'
106
119
 
107
120
  // ensure result dirs exist
108
121
  await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => {})
@@ -147,8 +160,8 @@ export class iOSManage {
147
160
  await fs.mkdir(resultsDir, { recursive: true }).catch(() => {})
148
161
 
149
162
 
150
- const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000 // default 3 minutes
151
- const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1
163
+ const XCODEBUILD_TIMEOUT = parseInt(env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000 // default 3 minutes
164
+ const MAX_RETRIES = parseInt(env.MCP_XCODEBUILD_RETRIES || '', 10) || 1
152
165
 
153
166
  const tries = MAX_RETRIES + 1
154
167
  let lastStdout = ''
@@ -157,8 +170,8 @@ export class iOSManage {
157
170
 
158
171
  for (let attempt = 1; attempt <= tries; attempt++) {
159
172
  // Run xcodebuild with a watchdog
160
- const res = await new Promise<{ code: number | null, stdout: string, stderr: string, killedByWatchdog?: boolean }>((resolve) => {
161
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir })
173
+ const res = await new Promise<{ code: number | null, stdout: string, stderr: string, killedByWatchdog?: boolean }>((resolve) => {
174
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir, env })
162
175
  let stdout = ''
163
176
  let stderr = ''
164
177
 
@@ -215,7 +228,7 @@ export class iOSManage {
215
228
  if (lastErr) {
216
229
  // Include diagnostics and result bundle path when available; provide structured info useful for agents
217
230
  const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`
218
- const envSnapshot = { PATH: process.env.PATH }
231
+ const envSnapshot = { PATH: env.PATH }
219
232
  return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}`, diagnostics: { exitCode: (lastErr as any).code || null, invokedCommand, cwd: projectRootDir, envSnapshot } }
220
233
  }
221
234
 
@@ -20,6 +20,56 @@ export type ToolCallResult = Awaited<ReturnType<typeof wrapResponse>> | {
20
20
  }
21
21
  export type ToolHandler = (args: ToolCallArgs) => Promise<ToolCallResult>
22
22
 
23
+ export function getStringArg(args: ToolCallArgs, key: string): string | undefined {
24
+ const value = args[key]
25
+ return typeof value === 'string' ? value : undefined
26
+ }
27
+
28
+ export function requireStringArg(args: ToolCallArgs, key: string): string {
29
+ const value = getStringArg(args, key)
30
+ if (value === undefined) throw new Error(`Missing or invalid string argument: ${key}`)
31
+ return value
32
+ }
33
+
34
+ export function getNumberArg(args: ToolCallArgs, key: string): number | undefined {
35
+ const value = args[key]
36
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
37
+ }
38
+
39
+ export function requireNumberArg(args: ToolCallArgs, key: string): number {
40
+ const value = getNumberArg(args, key)
41
+ if (value === undefined) throw new Error(`Missing or invalid number argument: ${key}`)
42
+ return value
43
+ }
44
+
45
+ export function getBooleanArg(args: ToolCallArgs, key: string): boolean | undefined {
46
+ const value = args[key]
47
+ return typeof value === 'boolean' ? value : undefined
48
+ }
49
+
50
+ export function requireBooleanArg(args: ToolCallArgs, key: string): boolean {
51
+ const value = getBooleanArg(args, key)
52
+ if (value === undefined) throw new Error(`Missing or invalid boolean argument: ${key}`)
53
+ return value
54
+ }
55
+
56
+ export function getObjectArg<T extends Record<string, unknown>>(args: ToolCallArgs, key: string): T | undefined {
57
+ const value = args[key]
58
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
59
+ return value as T
60
+ }
61
+
62
+ export function requireObjectArg<T extends Record<string, unknown>>(args: ToolCallArgs, key: string): T {
63
+ const value = getObjectArg<T>(args, key)
64
+ if (value === undefined) throw new Error(`Missing or invalid object argument: ${key}`)
65
+ return value
66
+ }
67
+
68
+ export function getArrayArg<T>(args: ToolCallArgs, key: string): T[] | undefined {
69
+ const value = args[key]
70
+ return Array.isArray(value) ? value as T[] : undefined
71
+ }
72
+
23
73
  let actionSequence = 0
24
74
 
25
75
  export function nextActionId(actionType: string, timestamp: number) {