mobile-debug-mcp 0.24.6 → 0.24.7

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 (45) hide show
  1. package/.github/workflows/ci.yml +1 -3
  2. package/README.md +7 -0
  3. package/dist/manage/android.js +9 -5
  4. package/dist/manage/index.js +37 -23
  5. package/dist/manage/ios.js +12 -15
  6. package/dist/server/common.js +46 -0
  7. package/dist/server/tool-handlers.js +120 -33
  8. package/dist/server-core.js +1 -1
  9. package/dist/utils/android/utils.js +17 -5
  10. package/dist/utils/cli/idb/check-idb.js +1 -1
  11. package/docs/CHANGELOG.md +15 -10
  12. package/eslint.config.js +2 -47
  13. package/package.json +7 -6
  14. package/src/manage/android.ts +22 -11
  15. package/src/manage/index.ts +37 -16
  16. package/src/manage/ios.ts +28 -15
  17. package/src/server/common.ts +50 -0
  18. package/src/server/tool-handlers.ts +136 -32
  19. package/src/server-core.ts +1 -1
  20. package/src/utils/android/utils.ts +18 -7
  21. package/src/utils/cli/idb/check-idb.ts +1 -1
  22. package/test/device/automated/observe/capture_screenshot.android.smoke.ts +1 -1
  23. package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +1 -1
  24. package/test/device/automated/observe/get_logs.android.smoke.ts +1 -1
  25. package/test/device/automated/observe/get_logs.ios.smoke.ts +1 -1
  26. package/test/device/automated/observe/get_ui_tree.android.smoke.ts +1 -1
  27. package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +1 -1
  28. package/test/device/manual/interact/app_lifecycle.manual.ts +3 -3
  29. package/test/device/manual/observe/capture_screenshot.manual.ts +2 -2
  30. package/test/device/manual/observe/get_logs.manual.ts +2 -2
  31. package/test/device/manual/observe/get_ui_tree.manual.ts +2 -2
  32. package/test/device/manual/observe/logstream.manual.ts +1 -1
  33. package/test/device/manual/observe/screen_fingerprint.manual.ts +2 -2
  34. package/test/unit/manage/scoped_env.test.ts +137 -0
  35. package/test/unit/server/capture_screenshot.test.ts +17 -0
  36. package/test/unit/server/common.test.ts +18 -0
  37. package/test/unit/server/contract.test.ts +3 -0
  38. package/test/unit/server/get_logs.test.ts +17 -0
  39. package/test/unit/server/get_network_activity.test.ts +17 -0
  40. package/test/unit/server/get_ui_tree.test.ts +17 -0
  41. package/test/unit/server/response_shapes.test.ts +18 -0
  42. package/test/unit/server/start_log_stream.test.ts +37 -0
  43. package/.eslintignore +0 -5
  44. package/.eslintrc.cjs +0 -18
  45. package/eslint.config.cjs +0 -36
@@ -57,23 +57,36 @@ export function ensureAdbAvailable() {
57
57
  return { adbCmd: adb, ok: false, error: String(err) };
58
58
  }
59
59
  }
60
+ function mergeEnv(overrides) {
61
+ const env = {};
62
+ for (const [key, value] of Object.entries(process.env)) {
63
+ if (typeof value === 'string')
64
+ env[key] = value;
65
+ }
66
+ for (const [key, value] of Object.entries(overrides || {})) {
67
+ if (typeof value === 'string')
68
+ env[key] = value;
69
+ }
70
+ return env;
71
+ }
60
72
  /**
61
73
  * Prepare Gradle execution options for building an Android project.
62
74
  * Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
63
75
  */
64
- export async function prepareGradle(projectPath) {
76
+ export async function prepareGradle(projectPath, envOverrides = {}) {
77
+ const env = mergeEnv(envOverrides);
65
78
  const gradlewPath = path.join(projectPath, 'gradlew');
66
79
  const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
67
80
  const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
68
81
  // Start with a default task; callers may append/override via env flags
69
- const gradleArgs = [process.env.MCP_GRADLE_TASK || 'assembleDebug'];
82
+ const gradleArgs = [env.MCP_GRADLE_TASK || 'assembleDebug'];
70
83
  // Respect generic MCP_BUILD_JOBS and Android-specific MCP_GRADLE_WORKERS
71
- const workers = process.env.MCP_GRADLE_WORKERS || process.env.MCP_BUILD_JOBS;
84
+ const workers = env.MCP_GRADLE_WORKERS || env.MCP_BUILD_JOBS;
72
85
  if (workers) {
73
86
  gradleArgs.push(`--max-workers=${workers}`);
74
87
  }
75
88
  // Respect gradle cache env: default enabled; set MCP_GRADLE_CACHE=0 to disable
76
- if (process.env.MCP_GRADLE_CACHE === '0') {
89
+ if (env.MCP_GRADLE_CACHE === '0') {
77
90
  gradleArgs.push('-Dorg.gradle.caching=false');
78
91
  }
79
92
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
@@ -85,7 +98,6 @@ export async function prepareGradle(projectPath) {
85
98
  catch {
86
99
  gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] };
87
100
  }
88
- const env = Object.assign({}, process.env);
89
101
  // Ensure child processes can find Android platform-tools (adb, etc.) by
90
102
  // prepending the platform-tools directory to PATH for spawned processes.
91
103
  const adbPath = resolveAdbCmd();
@@ -24,7 +24,7 @@ async function runInstaller() {
24
24
  // prefer invoking the TS script via npx/tsx to ensure environment
25
25
  const runner = which('npx') ? 'npx' : which('tsx') ? 'tsx' : null;
26
26
  if (runner) {
27
- const args = runner === 'npx' ? ['tsx', './src/cli/idb/install-idb.ts'] : ['./src/cli/idb/install-idb.ts'];
27
+ const args = runner === 'npx' ? ['tsx', './src/utils/cli/idb/install-idb.ts'] : ['./src/utils/cli/idb/install-idb.ts'];
28
28
  const res = spawnSync(runner, args, { stdio: 'inherit' });
29
29
  return typeof res.status === 'number' ? res.status === 0 : false;
30
30
  }
package/docs/CHANGELOG.md CHANGED
@@ -2,8 +2,13 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
- ## [0.24.6]
6
- - minor changes
5
+ ## [0.24.7]
6
+ - Aligned runtime metadata with the published package version.
7
+ - Fixed stale CLI helper paths in npm scripts and the `idb` healthcheck helper.
8
+ - Simplified ESLint configuration by keeping the flat config and removing legacy config files.
9
+ - Updated CI to use the current automated device test runner.
10
+ - Tightened server handler argument parsing and added contract coverage for version and required-argument error responses.
11
+ - Scoped temporary build environment overrides to the duration of each build helper call and added regression coverage for env restoration.
7
12
 
8
13
  ## [0.24.5]
9
14
  - Improved snapshots
@@ -27,7 +32,7 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
27
32
 
28
33
  ## [0.23.0]
29
34
  - Added network monitoring
30
- - Added
35
+ - Added action-outcome classification tooling for backend-driven flows without visible UI changes.
31
36
 
32
37
  ## [0.22.0]
33
38
  - Added a portable `test-authoring` skill package and documented the repository's vendor-neutral skill format
@@ -38,14 +43,14 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
38
43
  - Fixed incorrect timeout
39
44
 
40
45
  ## [0.21.4]
41
- - updated `wait_for_ui` with better contract and observability
42
- - update `get_logs` to get better output
46
+ - Updated `wait_for_ui` with better contract and observability
47
+ - Updated `get_logs` to return more useful structured output
43
48
 
44
49
  ## [0.21.3]
45
50
  - Added structured logs
46
51
 
47
52
  ## [0.21.2]
48
- - Fixed screenshots not working, imnproved tool
53
+ - Fixed screenshots not working and improved the tool output
49
54
 
50
55
  ## [0.21.1]
51
56
  - Removed wait_for_element and renamed observe_until to wait_for_ui (obsolete references removed)
@@ -54,7 +59,7 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
54
59
  - Added `wait_for_ui` as a tool for agents to wait for things like API requests
55
60
 
56
61
  ## [0.20.1]
57
- - Fixes gradle home issue for android
62
+ - Fixed Gradle home handling for Android
58
63
 
59
64
  ## [0.20.0]
60
65
  - Added `get_system_status` tool and refactored system health checks into `src/system`.
@@ -63,8 +68,8 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
63
68
 
64
69
 
65
70
  ## [0.19.2]
66
- - Added healthcheck improvments
67
- - Added skills
71
+ - Added healthcheck improvements
72
+ - Added reusable agent skills
68
73
 
69
74
  ## [0.19.1]
70
75
 
@@ -113,7 +118,7 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
113
118
  ## [0.12.1]
114
119
  - Improve iOS build/install reliability: project auto-scan, explicit simulator destination, configurable watchdog timeout (MCP_XCODEBUILD_TIMEOUT) and retries (MCP_XCODEBUILD_RETRIES), and DerivedData fallback for locating .app artifacts.
115
120
  - Make install_app capable of building iOS projects before installing so agents can autonomously fix, build, install and validate apps.
116
- - Migrate CLI scripts into typed src/cli/* modules and update npm scripts; fix ESM import paths and lint issues.
121
+ - Migrate CLI scripts into typed source modules and update npm scripts; fix ESM import paths and lint issues.
117
122
  - Add preflight checks and idb resolution helpers (getIdbCmd, isIDBInstalled) and add idb_companion health checks.
118
123
  - Capture build stdout/stderr into build-results/ for easier diagnostics and surfaced suggestions when KMP frameworks are missing.
119
124
  - Add device test runner under test/device and gate device-dependent tests behind RUN_DEVICE_TESTS.
package/eslint.config.js CHANGED
@@ -3,7 +3,6 @@ import tsPlugin from '@typescript-eslint/eslint-plugin'
3
3
  import unusedImports from 'eslint-plugin-unused-imports'
4
4
 
5
5
  export default [
6
- // Files/directories to ignore
7
6
  {
8
7
  ignores: [
9
8
  'dist/',
@@ -11,55 +10,11 @@ export default [
11
10
  '.git/',
12
11
  '.vscode/',
13
12
  'coverage/',
14
- '.env',
13
+ '.env'
15
14
  ]
16
15
  },
17
- // Apply rules to JS/TS source
18
16
  {
19
- files: ['src/**/*.ts', 'src/**/*.js'],
20
- languageOptions: {
21
- parser: tsParser,
22
- parserOptions: {
23
- ecmaVersion: 2020,
24
- sourceType: 'module',
25
- project: './tsconfig.json'
26
- }
27
- },
28
- plugins: {
29
- '@typescript-eslint': tsPlugin,
30
- 'unused-imports': unusedImports
31
- },
32
- rules: {
33
- // Use plugin to error on unused imports and provide autofix where possible
34
- 'unused-imports/no-unused-imports': 'error',
35
- 'unused-imports/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
36
- // Disable the default TS rule to avoid duplicate warnings
37
- '@typescript-eslint/no-unused-vars': 'off'
38
- }
39
- },
40
- // Apply lighter rules to test files (no project reference to avoid TS project parsing)
41
- {
42
- files: ['test/**/*.ts', 'test/**/*.js'],
43
- languageOptions: {
44
- parser: tsParser,
45
- parserOptions: {
46
- ecmaVersion: 2020,
47
- sourceType: 'module'
48
- }
49
- },
50
- plugins: {
51
- '@typescript-eslint': tsPlugin,
52
- 'unused-imports': unusedImports
53
- },
54
- rules: {
55
- 'unused-imports/no-unused-imports': 'error',
56
- 'unused-imports/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
57
- '@typescript-eslint/no-unused-vars': 'off'
58
- }
59
- },
60
- // Apply rules to CLI tooling
61
- {
62
- files: ['src/cli/**/*.ts', 'src/cli/**/*.js'],
17
+ files: ['src/**/*.ts', 'src/**/*.js', 'test/**/*.ts', 'test/**/*.js'],
63
18
  languageOptions: {
64
19
  parser: tsParser,
65
20
  parserOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.24.6",
3
+ "version": "0.24.7",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,15 +10,16 @@
10
10
  "build": "tsc",
11
11
  "start": "node ./dist/server.js",
12
12
  "prepare": "npm run build",
13
- "healthcheck": "tsx ./src/cli/idb/check-idb.ts",
14
- "install-idb": "tsx ./src/cli/idb/install-idb.ts",
15
- "preflight-ios": "tsx ./src/cli/ios/preflight-ios.ts",
13
+ "healthcheck": "tsx ./src/utils/cli/idb/check-idb.ts",
14
+ "install-idb": "tsx ./src/utils/cli/idb/install-idb.ts",
15
+ "preflight-ios": "tsx ./src/utils/cli/ios/preflight-ios.ts",
16
16
  "test:unit": "tsx test/unit/index.ts",
17
17
  "test:integration": "npm run test:device",
18
18
  "test:device": "npm run build && tsx test/device/index.ts",
19
19
  "test": "npm run test:unit",
20
- "lint": "eslint --ext .ts,.js src test --quiet",
21
- "lint:fix": "eslint --ext .ts,.js src test --fix"
20
+ "lint": "eslint src test --quiet",
21
+ "lint:fix": "eslint src test --fix",
22
+ "verify": "npm run lint && npm run build && npm run test:unit"
22
23
  },
23
24
 
24
25
  "engines": {
@@ -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) {