mobile-debug-mcp 0.12.2 → 0.12.4

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.
@@ -11,7 +11,17 @@ export async function prepareGradle(projectPath) {
11
11
  const gradlewPath = path.join(projectPath, 'gradlew');
12
12
  const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
13
13
  const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
14
- const gradleArgs = ['assembleDebug'];
14
+ // Start with a default task; callers may append/override via env flags
15
+ const gradleArgs = [process.env.MCP_GRADLE_TASK || 'assembleDebug'];
16
+ // Respect generic MCP_BUILD_JOBS and Android-specific MCP_GRADLE_WORKERS
17
+ const workers = process.env.MCP_GRADLE_WORKERS || process.env.MCP_BUILD_JOBS;
18
+ if (workers) {
19
+ gradleArgs.push(`--max-workers=${workers}`);
20
+ }
21
+ // Respect gradle cache env: default enabled; set MCP_GRADLE_CACHE=0 to disable
22
+ if (process.env.MCP_GRADLE_CACHE === '0') {
23
+ gradleArgs.push('-Dorg.gradle.caching=false');
24
+ }
15
25
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
16
26
  const env = Object.assign({}, process.env);
17
27
  if (detectedJavaHome) {
@@ -3,11 +3,16 @@ import { spawn, spawnSync } from "child_process";
3
3
  import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js";
4
4
  import path from "path";
5
5
  export class iOSManage {
6
- async build(projectPath, _variant) {
7
- void _variant;
6
+ async build(projectPath, optsOrVariant) {
7
+ // Support legacy variant string as second arg
8
+ let opts = {};
9
+ if (typeof optsOrVariant === 'string')
10
+ opts.variant = optsOrVariant;
11
+ else
12
+ opts = optsOrVariant || {};
8
13
  try {
9
14
  // 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) {
15
+ async function findProject(root, maxDepth = 4) {
11
16
  try {
12
17
  const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
13
18
  for (const e of ents) {
@@ -36,14 +41,31 @@ export class iOSManage {
36
41
  }
37
42
  // Resolve projectPath to an absolute path to avoid cwd-relative resolution issues
38
43
  const absProjectPath = path.resolve(projectPath);
39
- const projectInfo = await findProject(absProjectPath, 3);
40
- if (!projectInfo)
41
- return { error: 'No Xcode project or workspace found' };
42
- const projectRootDir = projectInfo.dir || absProjectPath;
43
- const workspace = projectInfo.workspace;
44
- const proj = projectInfo.proj;
45
- // Determine destination: prefer explicit env var, otherwise use booted simulator UDID
46
- let destinationUDID = process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || '';
44
+ // If caller supplied explicit workspace/project, prefer those and set projectRootDir accordingly
45
+ let projectRootDir = absProjectPath;
46
+ let workspace = opts.workspace;
47
+ let proj = opts.project;
48
+ if (workspace) {
49
+ // normalize workspace path and set root to its parent
50
+ workspace = path.isAbsolute(workspace) ? workspace : path.join(absProjectPath, workspace);
51
+ projectRootDir = path.dirname(workspace);
52
+ workspace = path.basename(workspace);
53
+ }
54
+ else if (proj) {
55
+ proj = path.isAbsolute(proj) ? proj : path.join(absProjectPath, proj);
56
+ projectRootDir = path.dirname(proj);
57
+ proj = path.basename(proj);
58
+ }
59
+ else {
60
+ const projectInfo = await findProject(absProjectPath, 4);
61
+ if (!projectInfo)
62
+ return { error: 'No Xcode project or workspace found' };
63
+ projectRootDir = projectInfo.dir || absProjectPath;
64
+ workspace = projectInfo.workspace;
65
+ proj = projectInfo.proj;
66
+ }
67
+ // 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 || '';
47
69
  if (!destinationUDID) {
48
70
  try {
49
71
  const meta = await getIOSDeviceMetadata('booted');
@@ -53,7 +75,7 @@ export class iOSManage {
53
75
  catch { }
54
76
  }
55
77
  // Determine xcode command early so it can be used when detecting schemes
56
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
78
+ const xcodeCmd = opts.xcodeCmd || process.env.XCODEBUILD_PATH || 'xcodebuild';
57
79
  // Determine available schemes by querying xcodebuild -list rather than guessing
58
80
  async function detectScheme(xcodeCmdInner, workspacePath, projectPathFull, cwd) {
59
81
  try {
@@ -72,30 +94,55 @@ export class iOSManage {
72
94
  catch { }
73
95
  return null;
74
96
  }
97
+ // Prepare build flags and paths (support incremental builds)
75
98
  let buildArgs;
76
- let chosenScheme = null;
99
+ let chosenScheme = opts.scheme || null;
100
+ // Derived data and result bundle (agent-configurable)
101
+ const derivedDataPath = opts.derivedDataPath || process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData');
102
+ // 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';
106
+ // ensure result dirs exist
107
+ await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => { });
108
+ await fs.mkdir(derivedDataPath, { recursive: true }).catch(() => { });
109
+ // remove any pre-existing result bundle path to avoid xcodebuild complaining
110
+ await fs.rm(resultBundlePath, { recursive: true, force: true }).catch(() => { });
77
111
  if (workspace) {
78
112
  const workspacePath = path.join(projectRootDir, workspace);
79
- chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
113
+ if (!chosenScheme)
114
+ chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
80
115
  const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '');
81
116
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
82
117
  }
83
118
  else {
84
119
  const projectPathFull = path.join(projectRootDir, proj);
85
- chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir);
120
+ if (!chosenScheme)
121
+ chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir);
86
122
  const scheme = chosenScheme || proj.replace(/\.xcodeproj$/, '');
87
123
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
88
124
  }
125
+ // Insert clean if explicitly requested via env or opts
126
+ if (forceClean) {
127
+ const idx = buildArgs.indexOf('build');
128
+ if (idx >= 0)
129
+ buildArgs.splice(idx, 0, 'clean');
130
+ }
89
131
  // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
90
132
  if (destinationUDID) {
91
133
  buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`);
92
134
  }
93
- // Add result bundle path for diagnostics
135
+ // Add derived data and result bundle for diagnostics and faster incremental builds
136
+ buildArgs.push('-derivedDataPath', derivedDataPath);
137
+ buildArgs.push('-resultBundlePath', resultBundlePath);
138
+ // parallelisation and jobs
139
+ buildArgs.push('-parallelizeTargets');
140
+ buildArgs.push('-jobs', String(xcodeJobs));
141
+ // Prepare results directory for backwards-compatible logs
94
142
  const resultsDir = path.join(projectPath, 'build-results');
95
143
  // Remove any stale results to avoid xcodebuild complaining about existing result bundles
96
144
  await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
97
145
  await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
98
- // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
99
146
  const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
100
147
  const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
101
148
  const tries = MAX_RETRIES + 1;
package/dist/ios/utils.js CHANGED
@@ -239,6 +239,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
239
239
  }
240
240
  export async function listIOSDevices(appId) {
241
241
  return new Promise((resolve) => {
242
+ // Query all devices and separately query booted devices to mark them
242
243
  execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
243
244
  if (err || !stdout)
244
245
  return resolve([]);
@@ -247,32 +248,50 @@ export async function listIOSDevices(appId) {
247
248
  const devicesMap = data.devices || {};
248
249
  const out = [];
249
250
  const checks = [];
250
- for (const runtime in devicesMap) {
251
- const devices = devicesMap[runtime];
252
- if (Array.isArray(devices)) {
253
- for (const device of devices) {
254
- const info = {
255
- platform: 'ios',
256
- id: device.udid,
257
- osVersion: parseRuntimeName(runtime),
258
- model: device.name,
259
- simulator: true
260
- };
261
- if (appId) {
262
- // check if installed
263
- const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
264
- .then(() => { info.appInstalled = true; })
265
- .catch(() => { info.appInstalled = false; })
266
- .then(() => { out.push(info); });
267
- checks.push(p);
251
+ // Get booted devices set
252
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
253
+ const bootedSet = new Set();
254
+ if (!err2 && stdout2) {
255
+ try {
256
+ const bdata = JSON.parse(stdout2);
257
+ const bmap = bdata.devices || {};
258
+ for (const rt in bmap) {
259
+ const devs = bmap[rt];
260
+ if (Array.isArray(devs))
261
+ for (const d of devs)
262
+ bootedSet.add(d.udid);
268
263
  }
269
- else {
270
- out.push(info);
264
+ }
265
+ catch { }
266
+ }
267
+ for (const runtime in devicesMap) {
268
+ const devices = devicesMap[runtime];
269
+ if (Array.isArray(devices)) {
270
+ for (const device of devices) {
271
+ const info = {
272
+ platform: 'ios',
273
+ id: device.udid,
274
+ osVersion: parseRuntimeName(runtime),
275
+ model: device.name,
276
+ simulator: true,
277
+ booted: bootedSet.has(device.udid)
278
+ };
279
+ if (appId) {
280
+ // check if installed
281
+ const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
282
+ .then(() => { info.appInstalled = true; })
283
+ .catch(() => { info.appInstalled = false; })
284
+ .then(() => { out.push(info); });
285
+ checks.push(p);
286
+ }
287
+ else {
288
+ out.push(info);
289
+ }
271
290
  }
272
291
  }
273
292
  }
274
- }
275
- Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
293
+ Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
294
+ });
276
295
  }
277
296
  catch {
278
297
  resolve([]);
package/dist/server.js CHANGED
@@ -115,28 +115,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
115
115
  },
116
116
  {
117
117
  name: "install_app",
118
- description: "Install an app on Android or iOS. Accepts a built binary (apk/.ipa/.app) or a project directory to build then install.",
118
+ description: "Install an app on Android or iOS. Accepts a built binary (apk/.ipa/.app) or a project directory to build then install. platform and projectType are required.",
119
119
  inputSchema: {
120
120
  type: "object",
121
121
  properties: {
122
- platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from appPath/project files." },
122
+ platform: { type: "string", enum: ["android", "ios"], description: "Platform to install to (required)." },
123
+ projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Project type to guide build/install tool selection (required)." },
123
124
  appPath: { type: "string", description: "Path to APK, .app, .ipa, or project directory" },
124
125
  deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
125
126
  },
126
- required: ["appPath"]
127
+ required: ["platform", "projectType", "appPath"]
127
128
  }
128
129
  },
129
130
  {
130
131
  name: "build_app",
131
- description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
132
+ description: "Build a project for Android or iOS and return the built artifact path. Does not install. platform and projectType are required.",
132
133
  inputSchema: {
133
134
  type: "object",
134
135
  properties: {
135
- platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
136
+ platform: { type: "string", enum: ["android", "ios"], description: "Platform to build for (required)." },
137
+ projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Project type to guide build tool selection (required)." },
136
138
  projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
137
139
  variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
138
140
  },
139
- required: ["projectPath"]
141
+ required: ["platform", "projectType", "projectPath"]
140
142
  }
141
143
  },
142
144
  {
@@ -414,8 +416,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
414
416
  return wrapResponse(response);
415
417
  }
416
418
  if (name === "install_app") {
417
- const { platform, appPath, deviceId } = args;
418
- const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId });
419
+ const { platform, projectType, appPath, deviceId } = args;
420
+ const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId, projectType });
419
421
  const response = {
420
422
  device: res.device,
421
423
  installed: res.installed,
@@ -425,13 +427,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
425
427
  return wrapResponse(response);
426
428
  }
427
429
  if (name === "build_app") {
428
- const { platform, projectPath, variant } = args;
429
- const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant });
430
+ const { platform, projectType, projectPath, variant } = args;
431
+ const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant, projectType });
430
432
  return wrapResponse(res);
431
433
  }
432
434
  if (name === 'build_and_install') {
433
- const { platform, projectPath, deviceId, timeout } = args;
434
- const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout });
435
+ const { platform, projectType, projectPath, deviceId, timeout } = args;
436
+ const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType });
435
437
  // res: { ndjson, result }
436
438
  return {
437
439
  content: [
@@ -3,8 +3,196 @@ import path from 'path';
3
3
  import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
4
4
  import { AndroidManage } from '../android/manage.js';
5
5
  import { iOSManage } from '../ios/manage.js';
6
+ import { findApk } from '../android/utils.js';
7
+ import { findAppBundle } from '../ios/utils.js';
8
+ import { execSync } from 'child_process';
9
+ export async function detectProjectPlatform(projectPath) {
10
+ // Recursively scan up to a limited depth for platform markers to avoid mis-detection
11
+ async function scan(dir, depth = 3) {
12
+ const res = { ios: false, android: false };
13
+ try {
14
+ const ents = await fs.readdir(dir).catch(() => []);
15
+ for (const e of ents) {
16
+ if (e.endsWith('.xcworkspace') || e.endsWith('.xcodeproj'))
17
+ res.ios = true;
18
+ if (e === 'gradlew' || e === 'build.gradle' || e === 'settings.gradle')
19
+ res.android = true;
20
+ if (res.ios && res.android)
21
+ return res;
22
+ }
23
+ if (depth <= 0)
24
+ return res;
25
+ for (const e of ents) {
26
+ try {
27
+ const full = path.join(dir, e);
28
+ const st = await fs.stat(full).catch(() => null);
29
+ if (st && st.isDirectory()) {
30
+ const child = await scan(full, depth - 1);
31
+ if (child.ios)
32
+ res.ios = true;
33
+ if (child.android)
34
+ res.android = true;
35
+ if (res.ios && res.android)
36
+ return res;
37
+ }
38
+ }
39
+ catch { }
40
+ }
41
+ }
42
+ catch { }
43
+ return res;
44
+ }
45
+ try {
46
+ const stat = await fs.stat(projectPath).catch(() => null);
47
+ if (stat && stat.isDirectory()) {
48
+ const { ios: hasIos, android: hasAndroid } = await scan(projectPath, 3);
49
+ if (hasIos && !hasAndroid)
50
+ return 'ios';
51
+ if (hasAndroid && !hasIos)
52
+ return 'android';
53
+ if (hasIos && hasAndroid)
54
+ return 'ambiguous';
55
+ // no explicit markers found
56
+ return 'unknown';
57
+ }
58
+ else {
59
+ const ext = path.extname(projectPath).toLowerCase();
60
+ if (ext === '.apk')
61
+ return 'android';
62
+ if (ext === '.ipa' || ext === '.app')
63
+ return 'ios';
64
+ return 'unknown';
65
+ }
66
+ }
67
+ catch {
68
+ return 'unknown';
69
+ }
70
+ }
6
71
  export class ToolsManage {
7
- static async buildAppHandler({ platform, projectPath, variant }) {
72
+ static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }) {
73
+ const android = new AndroidManage();
74
+ // prepare gradle options via environment hints
75
+ if (typeof maxWorkers === 'number')
76
+ process.env.MCP_GRADLE_WORKERS = String(maxWorkers);
77
+ if (typeof gradleCache === 'boolean')
78
+ process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0';
79
+ if (forceClean)
80
+ process.env.MCP_FORCE_CLEAN_ANDROID = '1';
81
+ const task = gradleTask || 'assembleDebug';
82
+ const artifact = await android.build(projectPath, task);
83
+ return artifact;
84
+ }
85
+ static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
86
+ const ios = new iOSManage();
87
+ // Use provided options rather than env-only; still set env fallbacks for downstream tools
88
+ if (derivedDataPath)
89
+ process.env.MCP_DERIVED_DATA = derivedDataPath;
90
+ if (typeof buildJobs === 'number')
91
+ process.env.MCP_BUILD_JOBS = String(buildJobs);
92
+ if (forceClean)
93
+ process.env.MCP_FORCE_CLEAN_IOS = '1';
94
+ if (destinationUDID)
95
+ process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
96
+ const opts = {};
97
+ if (_workspace)
98
+ opts.workspace = _workspace;
99
+ if (_project)
100
+ opts.project = _project;
101
+ if (_scheme)
102
+ opts.scheme = _scheme;
103
+ if (destinationUDID)
104
+ opts.destinationUDID = destinationUDID;
105
+ if (derivedDataPath)
106
+ opts.derivedDataPath = derivedDataPath;
107
+ if (forceClean)
108
+ opts.forceClean = forceClean;
109
+ // prefer explicit xcodebuild path from env
110
+ if (process.env.XCODEBUILD_PATH)
111
+ opts.xcodeCmd = process.env.XCODEBUILD_PATH;
112
+ const artifact = await ios.build(projectPath, opts);
113
+ return artifact;
114
+ }
115
+ static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
116
+ // Prefer using flutter CLI when available; otherwise delegate to native subproject builders
117
+ const flutterCmd = process.env.FLUTTER_PATH || 'flutter';
118
+ // silence unused params
119
+ void _maxWorkers;
120
+ void _forceClean;
121
+ try {
122
+ // Check flutter presence without streaming output
123
+ execSync(`${flutterCmd} --version`, { stdio: 'ignore' });
124
+ if (!platform || platform === 'android') {
125
+ const mode = buildMode || 'debug';
126
+ try {
127
+ const out = execSync(`${flutterCmd} build apk --${mode}`, { cwd: projectPath, encoding: 'utf8' });
128
+ // Try to find built APK
129
+ const apk = await findApk(path.join(projectPath));
130
+ if (apk)
131
+ return { artifactPath: apk, output: out };
132
+ }
133
+ catch (err) {
134
+ const stdout = err && err.stdout ? String(err.stdout) : '';
135
+ const stderr = err && err.stderr ? String(err.stderr) : '';
136
+ throw new Error(`flutter build apk failed: ${stderr || stdout || err.message}`);
137
+ }
138
+ }
139
+ if (!platform || platform === 'ios') {
140
+ const mode = buildMode || 'debug';
141
+ try {
142
+ const out = execSync(`${flutterCmd} build ios --${mode} --no-codesign`, { cwd: projectPath, encoding: 'utf8' });
143
+ const app = await findAppBundle(path.join(projectPath));
144
+ if (app)
145
+ return { artifactPath: app, output: out };
146
+ }
147
+ catch (err) {
148
+ const stdout = err && err.stdout ? String(err.stdout) : '';
149
+ const stderr = err && err.stderr ? String(err.stderr) : '';
150
+ throw new Error(`flutter build ios failed: ${stderr || stdout || err.message}`);
151
+ }
152
+ }
153
+ }
154
+ catch (e) {
155
+ // If flutter CLI not available or command fails, fall back to native subprojects
156
+ // Preserve error message for diagnostics if needed
157
+ void e;
158
+ }
159
+ // Fallback: try native subproject builds
160
+ if (!platform || platform === 'android') {
161
+ const androidDir = path.join(projectPath, 'android');
162
+ const android = new AndroidManage();
163
+ const artifact = await android.build(androidDir, _forceClean ? 'clean && assembleDebug' : 'assembleDebug');
164
+ return artifact;
165
+ }
166
+ if (!platform || platform === 'ios') {
167
+ const iosDir = path.join(projectPath, 'ios');
168
+ const ios = new iOSManage();
169
+ const artifact = await ios.build(iosDir);
170
+ return artifact;
171
+ }
172
+ return { error: 'Unable to build flutter project' };
173
+ }
174
+ static async build_react_native({ projectPath, platform, variant, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
175
+ // silence unused params
176
+ void _maxWorkers;
177
+ void _forceClean;
178
+ // React Native typically uses native subprojects. Delegate to Android/iOS builders.
179
+ if (!platform || platform === 'android') {
180
+ const androidDir = path.join(projectPath, 'android');
181
+ const android = new AndroidManage();
182
+ const artifact = await android.build(androidDir, variant || 'assembleDebug');
183
+ return artifact;
184
+ }
185
+ if (!platform || platform === 'ios') {
186
+ const iosDir = path.join(projectPath, 'ios');
187
+ // Recommend running `pod install` prior to building in CI; not performed automatically here
188
+ const ios = new iOSManage();
189
+ const artifact = await ios.build(iosDir);
190
+ return artifact;
191
+ }
192
+ return { error: 'Unable to build react-native project' };
193
+ }
194
+ static async buildAppHandler({ platform, projectPath, variant, projectType: _projectType }) {
195
+ void _projectType;
8
196
  // delegate to platform-specific build implementations
9
197
  const chosen = platform || 'android';
10
198
  if (chosen === 'android') {
@@ -18,41 +206,12 @@ export class ToolsManage {
18
206
  return artifact;
19
207
  }
20
208
  }
21
- static async installAppHandler({ platform, appPath, deviceId }) {
22
- let chosenPlatform = platform;
23
- try {
24
- const stat = await fs.stat(appPath).catch(() => null);
25
- if (stat && stat.isDirectory()) {
26
- // If the directory itself looks like an .app bundle, treat as iOS
27
- if (appPath.endsWith('.app')) {
28
- chosenPlatform = 'ios';
29
- }
30
- else {
31
- const files = (await fs.readdir(appPath).catch(() => []));
32
- if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
33
- chosenPlatform = 'ios';
34
- }
35
- else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
36
- chosenPlatform = 'android';
37
- }
38
- else {
39
- chosenPlatform = 'android';
40
- }
41
- }
42
- }
43
- else if (typeof appPath === 'string') {
44
- const ext = path.extname(appPath).toLowerCase();
45
- if (ext === '.apk')
46
- chosenPlatform = 'android';
47
- else if (ext === '.ipa' || ext === '.app')
48
- chosenPlatform = 'ios';
49
- else
50
- chosenPlatform = 'android';
51
- }
52
- }
53
- catch {
54
- chosenPlatform = 'android';
209
+ static async installAppHandler({ platform, appPath, deviceId, projectType }) {
210
+ // Enforce explicit platform and projectType: both are mandatory to avoid ambiguity
211
+ if (!platform || !projectType) {
212
+ throw new Error('Both platform and projectType parameters are required (platform: ios|android, projectType: native|kmp|react-native|flutter).');
55
213
  }
214
+ const chosenPlatform = platform;
56
215
  if (chosenPlatform === 'android') {
57
216
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
58
217
  const androidRun = new AndroidManage();
@@ -106,36 +265,59 @@ export class ToolsManage {
106
265
  return await new iOSManage().resetAppData(appId, resolved.id);
107
266
  }
108
267
  }
109
- static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
268
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }) {
110
269
  const events = [];
111
270
  const pushEvent = (obj) => events.push(JSON.stringify(obj));
112
271
  const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
113
272
  void effectiveTimeout;
114
- // determine platform if not provided by inspecting path
273
+ // Require explicit platform and projectType to avoid ambiguous autodetection
274
+ if (!platform || !projectType) {
275
+ pushEvent({ type: 'build', status: 'failed', error: 'Both platform and projectType parameters are required.' });
276
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Both platform and projectType parameters are required (platform: ios|android, projectType: native|kmp|react-native|flutter).' } };
277
+ }
278
+ // determine platform if not provided by inspecting path or projectType hint
115
279
  let chosenPlatform = platform;
116
280
  try {
117
- const stat = await fs.stat(projectPath).catch(() => null);
118
281
  if (!chosenPlatform) {
119
- if (stat && stat.isDirectory()) {
120
- const files = (await fs.readdir(projectPath).catch(() => []));
121
- if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')))
122
- chosenPlatform = 'ios';
123
- else
282
+ // If caller provided projectType, respect it as a hard override and map to platform
283
+ if (projectType) {
284
+ if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
124
285
  chosenPlatform = 'android';
286
+ pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing android platform` });
287
+ }
288
+ else if (projectType === 'native' || projectType === 'ios') {
289
+ chosenPlatform = 'ios';
290
+ pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing ios platform` });
291
+ }
292
+ else {
293
+ pushEvent({ type: 'build', status: 'failed', error: `Unknown projectType: ${projectType}` });
294
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: `Unknown projectType: ${projectType}` } };
295
+ }
125
296
  }
126
297
  else {
127
- const ext = path.extname(projectPath).toLowerCase();
128
- if (ext === '.apk')
129
- chosenPlatform = 'android';
130
- else if (ext === '.ipa' || ext === '.app')
131
- chosenPlatform = 'ios';
132
- else
133
- chosenPlatform = 'android';
298
+ // If autodetect is disabled, require explicit platform or projectType
299
+ if (process.env.MCP_DISABLE_AUTODETECT === '1') {
300
+ pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' });
301
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } };
302
+ }
303
+ const det = await detectProjectPlatform(projectPath);
304
+ if (det === 'ios' || det === 'android') {
305
+ chosenPlatform = det;
306
+ }
307
+ else if (det === 'ambiguous') {
308
+ pushEvent({ type: 'build', status: 'failed', error: 'Ambiguous project (contains both iOS and Android). Please provide platform: "ios" or "android".' });
309
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Ambiguous project - please provide explicit platform parameter (ios|android).' } };
310
+ }
311
+ else {
312
+ // Unknown project type - do not guess. Request explicit platform.
313
+ pushEvent({ type: 'build', status: 'failed', error: 'Unknown project type - unable to autodetect platform. Please provide platform or projectType.' });
314
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Unknown project type - please provide platform or projectType (ios|android).' } };
315
+ }
134
316
  }
135
317
  }
136
318
  }
137
319
  catch {
138
- chosenPlatform = chosenPlatform || 'android';
320
+ // detection failed; avoid guessing a platform
139
321
  }
140
322
  pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
141
323
  let buildRes;
@@ -157,7 +339,7 @@ export class ToolsManage {
157
339
  pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
158
340
  let installRes;
159
341
  try {
160
- installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId });
342
+ installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId, projectType });
161
343
  if (installRes && installRes.installed === true) {
162
344
  pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
163
345
  return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
@@ -49,6 +49,14 @@ export async function resolveTargetDevice(opts) {
49
49
  if (physical.length > 1)
50
50
  candidates = physical;
51
51
  }
52
+ // Prefer booted iOS simulators if present
53
+ if (platform === 'ios') {
54
+ const booted = candidates.filter((d) => !!d.booted);
55
+ if (booted.length === 1)
56
+ return booted[0];
57
+ if (booted.length > 1)
58
+ return booted[0]; // if multiple booted, pick the first
59
+ }
52
60
  candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion));
53
61
  if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
54
62
  return candidates[0];