mobile-debug-mcp 0.12.1 → 0.12.3

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Mobile Dev MCP
1
+ # Mobile Dev Tools
2
2
 
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
 
@@ -1,24 +1 @@
1
- import { spawnSync } from 'child_process';
2
- import { getAdbCmd } from './utils.js';
3
- import { makeEnvSnapshot } from '../utils/diagnostics.js';
4
- export function execAdbWithDiagnostics(args, deviceId) {
5
- const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
6
- const timeout = 120000;
7
- const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout });
8
- const runResult = {
9
- exitCode: typeof res.status === 'number' ? res.status : null,
10
- stdout: res.stdout || '',
11
- stderr: res.stderr || '',
12
- envSnapshot: makeEnvSnapshot(['PATH', 'ADB_PATH', 'HOME', 'JAVA_HOME']),
13
- command: getAdbCmd(),
14
- args: adbArgs,
15
- suggestedFixes: []
16
- };
17
- if (res.status !== 0) {
18
- if ((runResult.stderr || '').includes('device not found'))
19
- runResult.suggestedFixes.push('Ensure device is connected and adb is authorized (adb devices)');
20
- if ((runResult.stderr || '').includes('No such file or directory'))
21
- runResult.suggestedFixes.push('Verify ADB_PATH or that adb is installed');
22
- }
23
- return { runResult };
24
- }
1
+ export { execAdbWithDiagnostics } from '../utils/diagnostics.js';
@@ -3,7 +3,7 @@ import { spawn } from 'child_process';
3
3
  import path from 'path';
4
4
  import { existsSync } from 'fs';
5
5
  import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js';
6
- import { execAdbWithDiagnostics } from './diagnostics.js';
6
+ import { execAdbWithDiagnostics } from '../utils/diagnostics.js';
7
7
  import { detectJavaHome } from '../utils/java.js';
8
8
  export class AndroidManage {
9
9
  async build(projectPath, _variant) {
@@ -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) {
@@ -1,5 +1,5 @@
1
1
  import { promises as fs } from "fs";
2
- import { spawn } from "child_process";
2
+ 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 {
@@ -34,10 +34,12 @@ export class iOSManage {
34
34
  catch { }
35
35
  return null;
36
36
  }
37
- const projectInfo = await findProject(projectPath, 3);
37
+ // Resolve projectPath to an absolute path to avoid cwd-relative resolution issues
38
+ const absProjectPath = path.resolve(projectPath);
39
+ const projectInfo = await findProject(absProjectPath, 3);
38
40
  if (!projectInfo)
39
41
  return { error: 'No Xcode project or workspace found' };
40
- const projectRootDir = projectInfo.dir || projectPath;
42
+ const projectRootDir = projectInfo.dir || absProjectPath;
41
43
  const workspace = projectInfo.workspace;
42
44
  const proj = projectInfo.proj;
43
45
  // Determine destination: prefer explicit env var, otherwise use booted simulator UDID
@@ -50,28 +52,70 @@ export class iOSManage {
50
52
  }
51
53
  catch { }
52
54
  }
55
+ // Determine xcode command early so it can be used when detecting schemes
56
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
57
+ // Determine available schemes by querying xcodebuild -list rather than guessing
58
+ async function detectScheme(xcodeCmdInner, workspacePath, projectPathFull, cwd) {
59
+ try {
60
+ const args = workspacePath ? ['-list', '-workspace', workspacePath] : ['-list', '-project', projectPathFull];
61
+ // Run xcodebuild directly to list schemes
62
+ const res = spawnSync(xcodeCmdInner, args, { cwd: cwd || projectRootDir, encoding: 'utf8', timeout: 20000 });
63
+ const out = res.stdout || '';
64
+ const schemesMatch = out.match(/Schemes:\s*\n([\s\S]*?)(?:\n\n|$)/m);
65
+ if (schemesMatch) {
66
+ const block = schemesMatch[1];
67
+ const schemes = block.split(/\n/).map(s => s.trim()).filter(Boolean);
68
+ if (schemes.length)
69
+ return schemes[0];
70
+ }
71
+ }
72
+ catch { }
73
+ return null;
74
+ }
75
+ // Prepare build flags and paths (support incremental builds)
53
76
  let buildArgs;
77
+ let chosenScheme = null;
78
+ // Derived data and result bundle (agent-configurable)
79
+ const derivedDataPath = process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData');
80
+ const resultBundlePath = path.join(projectRootDir, 'build', 'xcresults', 'ResultBundle.xcresult');
81
+ const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4;
82
+ const forceClean = process.env.MCP_FORCE_CLEAN === '1';
83
+ // ensure result dirs exist
84
+ await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => { });
85
+ await fs.mkdir(derivedDataPath, { recursive: true }).catch(() => { });
54
86
  if (workspace) {
55
87
  const workspacePath = path.join(projectRootDir, workspace);
56
- const scheme = workspace.replace(/\.xcworkspace$/, '');
88
+ chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
89
+ const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '');
57
90
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
58
91
  }
59
92
  else {
60
93
  const projectPathFull = path.join(projectRootDir, proj);
61
- const scheme = proj.replace(/\.xcodeproj$/, '');
94
+ chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir);
95
+ const scheme = chosenScheme || proj.replace(/\.xcodeproj$/, '');
62
96
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
63
97
  }
98
+ // Insert clean if explicitly requested via env
99
+ if (forceClean) {
100
+ const idx = buildArgs.indexOf('build');
101
+ if (idx >= 0)
102
+ buildArgs.splice(idx, 0, 'clean');
103
+ }
64
104
  // If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
65
105
  if (destinationUDID) {
66
106
  buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`);
67
107
  }
68
- // Add result bundle path for diagnostics
108
+ // Add derived data and result bundle for diagnostics and faster incremental builds
109
+ buildArgs.push('-derivedDataPath', derivedDataPath);
110
+ buildArgs.push('-resultBundlePath', resultBundlePath);
111
+ // parallelisation and jobs
112
+ buildArgs.push('-parallelizeTargets');
113
+ buildArgs.push('-jobs', String(xcodeJobs));
114
+ // Prepare results directory for backwards-compatible logs
69
115
  const resultsDir = path.join(projectPath, 'build-results');
70
116
  // Remove any stale results to avoid xcodebuild complaining about existing result bundles
71
117
  await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
72
118
  await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
73
- // Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
74
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
75
119
  const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
76
120
  const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
77
121
  const tries = MAX_RETRIES + 1;
@@ -81,7 +125,7 @@ export class iOSManage {
81
125
  for (let attempt = 1; attempt <= tries; attempt++) {
82
126
  // Run xcodebuild with a watchdog
83
127
  const res = await new Promise((resolve) => {
84
- const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
128
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir });
85
129
  let stdout = '';
86
130
  let stderr = '';
87
131
  proc.stdout?.on('data', d => stdout += d.toString());
@@ -112,6 +156,9 @@ export class iOSManage {
112
156
  }
113
157
  // record the failure for reporting
114
158
  lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`);
159
+ lastErr.code = res.code;
160
+ lastErr.exitCode = res.code;
161
+ lastErr.killedByWatchdog = !!res.killedByWatchdog;
115
162
  // write logs for diagnostics (helpful whether killed or not)
116
163
  try {
117
164
  await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stdout.log`), res.stdout).catch(() => { });
@@ -127,8 +174,10 @@ export class iOSManage {
127
174
  break;
128
175
  }
129
176
  if (lastErr) {
130
- // Include diagnostics and result bundle path when available
131
- return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}` };
177
+ // Include diagnostics and result bundle path when available; provide structured info useful for agents
178
+ const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`;
179
+ const envSnapshot = { PATH: process.env.PATH };
180
+ 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 } };
132
181
  }
133
182
  // Try to locate built .app. First search project tree, then DerivedData if necessary
134
183
  const built = await findAppBundle(projectPath);
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
@@ -120,6 +120,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
120
120
  type: "object",
121
121
  properties: {
122
122
  platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from appPath/project files." },
123
+ projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
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
  },
@@ -133,6 +134,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
133
134
  type: "object",
134
135
  properties: {
135
136
  platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
137
+ projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
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
  },
@@ -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: [
@@ -1,4 +1,4 @@
1
- import { resolveTargetDevice } from '../resolve-device.js';
1
+ import { resolveTargetDevice } from '../utils/resolve-device.js';
2
2
  import { AndroidInteract } from '../android/interact.js';
3
3
  import { iOSInteract } from '../ios/interact.js';
4
4
  export class ToolsInteract {
@@ -1,10 +1,151 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
- import { resolveTargetDevice, listDevices } from '../resolve-device.js';
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
+ try {
11
+ const stat = await fs.stat(projectPath).catch(() => null);
12
+ if (stat && stat.isDirectory()) {
13
+ const files = (await fs.readdir(projectPath).catch(() => []));
14
+ const hasIos = files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'));
15
+ const hasAndroid = files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(projectPath, 'app')).catch(() => null)));
16
+ if (hasIos && !hasAndroid)
17
+ return 'ios';
18
+ if (hasAndroid && !hasIos)
19
+ return 'android';
20
+ if (hasIos && hasAndroid)
21
+ return 'ambiguous';
22
+ return 'unknown';
23
+ }
24
+ else {
25
+ const ext = path.extname(projectPath).toLowerCase();
26
+ if (ext === '.apk')
27
+ return 'android';
28
+ if (ext === '.ipa' || ext === '.app')
29
+ return 'ios';
30
+ return 'unknown';
31
+ }
32
+ }
33
+ catch {
34
+ return 'unknown';
35
+ }
36
+ }
6
37
  export class ToolsManage {
7
- static async buildAppHandler({ platform, projectPath, variant }) {
38
+ static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }) {
39
+ const android = new AndroidManage();
40
+ // prepare gradle options via environment hints
41
+ if (typeof maxWorkers === 'number')
42
+ process.env.MCP_GRADLE_WORKERS = String(maxWorkers);
43
+ if (typeof gradleCache === 'boolean')
44
+ process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0';
45
+ if (forceClean)
46
+ process.env.MCP_FORCE_CLEAN_ANDROID = '1';
47
+ const task = gradleTask || 'assembleDebug';
48
+ const artifact = await android.build(projectPath, task);
49
+ return artifact;
50
+ }
51
+ static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
52
+ const ios = new iOSManage();
53
+ // silence unused param lints
54
+ void _workspace;
55
+ void _project;
56
+ void _scheme;
57
+ if (derivedDataPath)
58
+ process.env.MCP_DERIVED_DATA = derivedDataPath;
59
+ if (typeof buildJobs === 'number')
60
+ process.env.MCP_BUILD_JOBS = String(buildJobs);
61
+ if (forceClean)
62
+ process.env.MCP_FORCE_CLEAN_IOS = '1';
63
+ if (destinationUDID)
64
+ process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
65
+ const artifact = await ios.build(projectPath);
66
+ return artifact;
67
+ }
68
+ static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
69
+ // Prefer using flutter CLI when available; otherwise delegate to native subproject builders
70
+ const flutterCmd = process.env.FLUTTER_PATH || 'flutter';
71
+ // silence unused params
72
+ void _maxWorkers;
73
+ void _forceClean;
74
+ try {
75
+ // Check flutter presence without streaming output
76
+ execSync(`${flutterCmd} --version`, { stdio: 'ignore' });
77
+ if (!platform || platform === 'android') {
78
+ const mode = buildMode || 'debug';
79
+ try {
80
+ const out = execSync(`${flutterCmd} build apk --${mode}`, { cwd: projectPath, encoding: 'utf8' });
81
+ // Try to find built APK
82
+ const apk = await findApk(path.join(projectPath));
83
+ if (apk)
84
+ return { artifactPath: apk, output: out };
85
+ }
86
+ catch (err) {
87
+ const stdout = err && err.stdout ? String(err.stdout) : '';
88
+ const stderr = err && err.stderr ? String(err.stderr) : '';
89
+ throw new Error(`flutter build apk failed: ${stderr || stdout || err.message}`);
90
+ }
91
+ }
92
+ if (!platform || platform === 'ios') {
93
+ const mode = buildMode || 'debug';
94
+ try {
95
+ const out = execSync(`${flutterCmd} build ios --${mode} --no-codesign`, { cwd: projectPath, encoding: 'utf8' });
96
+ const app = await findAppBundle(path.join(projectPath));
97
+ if (app)
98
+ return { artifactPath: app, output: out };
99
+ }
100
+ catch (err) {
101
+ const stdout = err && err.stdout ? String(err.stdout) : '';
102
+ const stderr = err && err.stderr ? String(err.stderr) : '';
103
+ throw new Error(`flutter build ios failed: ${stderr || stdout || err.message}`);
104
+ }
105
+ }
106
+ }
107
+ catch (e) {
108
+ // If flutter CLI not available or command fails, fall back to native subprojects
109
+ // Preserve error message for diagnostics if needed
110
+ void e;
111
+ }
112
+ // Fallback: try native subproject builds
113
+ if (!platform || platform === 'android') {
114
+ const androidDir = path.join(projectPath, 'android');
115
+ const android = new AndroidManage();
116
+ const artifact = await android.build(androidDir, _forceClean ? 'clean && assembleDebug' : 'assembleDebug');
117
+ return artifact;
118
+ }
119
+ if (!platform || platform === 'ios') {
120
+ const iosDir = path.join(projectPath, 'ios');
121
+ const ios = new iOSManage();
122
+ const artifact = await ios.build(iosDir);
123
+ return artifact;
124
+ }
125
+ return { error: 'Unable to build flutter project' };
126
+ }
127
+ static async build_react_native({ projectPath, platform, variant, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
128
+ // silence unused params
129
+ void _maxWorkers;
130
+ void _forceClean;
131
+ // React Native typically uses native subprojects. Delegate to Android/iOS builders.
132
+ if (!platform || platform === 'android') {
133
+ const androidDir = path.join(projectPath, 'android');
134
+ const android = new AndroidManage();
135
+ const artifact = await android.build(androidDir, variant || 'assembleDebug');
136
+ return artifact;
137
+ }
138
+ if (!platform || platform === 'ios') {
139
+ const iosDir = path.join(projectPath, 'ios');
140
+ // Recommend running `pod install` prior to building in CI; not performed automatically here
141
+ const ios = new iOSManage();
142
+ const artifact = await ios.build(iosDir);
143
+ return artifact;
144
+ }
145
+ return { error: 'Unable to build react-native project' };
146
+ }
147
+ static async buildAppHandler({ platform, projectPath, variant, projectType: _projectType }) {
148
+ void _projectType;
8
149
  // delegate to platform-specific build implementations
9
150
  const chosen = platform || 'android';
10
151
  if (chosen === 'android') {
@@ -18,8 +159,20 @@ export class ToolsManage {
18
159
  return artifact;
19
160
  }
20
161
  }
21
- static async installAppHandler({ platform, appPath, deviceId }) {
162
+ static async installAppHandler({ platform, appPath, deviceId, projectType }) {
163
+ // Use projectType hint to influence platform detection when explicit platform is not provided
22
164
  let chosenPlatform = platform;
165
+ if (!chosenPlatform && projectType) {
166
+ // Heuristic defaults: KMP, React Native and Flutter commonly target Android by default in CI
167
+ if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
168
+ chosenPlatform = 'android';
169
+ console.debug('[manage] projectType hint -> selecting android by default for', projectType);
170
+ }
171
+ else if (projectType === 'native' || projectType === 'ios') {
172
+ chosenPlatform = 'ios';
173
+ console.debug('[manage] projectType hint -> selecting ios by default for', projectType);
174
+ }
175
+ }
23
176
  try {
24
177
  const stat = await fs.stat(appPath).catch(() => null);
25
178
  if (stat && stat.isDirectory()) {
@@ -106,36 +259,44 @@ export class ToolsManage {
106
259
  return await new iOSManage().resetAppData(appId, resolved.id);
107
260
  }
108
261
  }
109
- static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
262
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }) {
110
263
  const events = [];
111
264
  const pushEvent = (obj) => events.push(JSON.stringify(obj));
112
265
  const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
113
266
  void effectiveTimeout;
114
- // determine platform if not provided by inspecting path
267
+ // determine platform if not provided by inspecting path or projectType hint
115
268
  let chosenPlatform = platform;
116
269
  try {
117
- const stat = await fs.stat(projectPath).catch(() => null);
118
270
  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
124
- chosenPlatform = 'android';
271
+ // If autodetect is disabled, require explicit platform or projectType
272
+ if (process.env.MCP_DISABLE_AUTODETECT === '1') {
273
+ pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' });
274
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } };
275
+ }
276
+ // If caller indicated KMP, prefer android by default (most KMP modul8 setups target Android)
277
+ if (projectType === 'kmp') {
278
+ chosenPlatform = 'android';
279
+ pushEvent({ type: 'build', status: 'info', message: 'projectType=kmp -> selecting android platform by default' });
125
280
  }
126
281
  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';
282
+ const det = await detectProjectPlatform(projectPath);
283
+ if (det === 'ios' || det === 'android') {
284
+ chosenPlatform = det;
285
+ }
286
+ else if (det === 'ambiguous') {
287
+ pushEvent({ type: 'build', status: 'failed', error: 'Ambiguous project (contains both iOS and Android). Please provide platform: "ios" or "android".' });
288
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Ambiguous project - please provide explicit platform parameter (ios|android).' } };
289
+ }
290
+ else {
291
+ // Unknown project type - do not guess. Request explicit platform.
292
+ pushEvent({ type: 'build', status: 'failed', error: 'Unknown project type - unable to autodetect platform. Please provide platform or projectType.' });
293
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'Unknown project type - please provide platform or projectType (ios|android).' } };
294
+ }
134
295
  }
135
296
  }
136
297
  }
137
298
  catch {
138
- chosenPlatform = chosenPlatform || 'android';
299
+ // detection failed; avoid guessing a platform
139
300
  }
140
301
  pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
141
302
  let buildRes;
@@ -1,4 +1,4 @@
1
- import { resolveTargetDevice } from '../resolve-device.js';
1
+ import { resolveTargetDevice } from '../utils/resolve-device.js';
2
2
  import { AndroidObserve } from '../android/observe.js';
3
3
  import { iOSObserve } from '../ios/observe.js';
4
4
  export class ToolsObserve {
@@ -23,3 +23,27 @@ export class DiagnosticError extends Error {
23
23
  this.runResult = runResult;
24
24
  }
25
25
  }
26
+ // Exec ADB with diagnostics — moved from src/android/diagnostics.ts
27
+ import { spawnSync } from 'child_process';
28
+ import { getAdbCmd } from '../android/utils.js';
29
+ export function execAdbWithDiagnostics(args, deviceId) {
30
+ const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
31
+ const timeout = 120000;
32
+ const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout });
33
+ const runResult = {
34
+ exitCode: typeof res.status === 'number' ? res.status : null,
35
+ stdout: res.stdout || '',
36
+ stderr: res.stderr || '',
37
+ envSnapshot: makeEnvSnapshot(['PATH', 'ADB_PATH', 'HOME', 'JAVA_HOME']),
38
+ command: getAdbCmd(),
39
+ args: adbArgs,
40
+ suggestedFixes: []
41
+ };
42
+ if (res.status !== 0) {
43
+ if ((runResult.stderr || '').includes('device not found'))
44
+ runResult.suggestedFixes.push('Ensure device is connected and adb is authorized (adb devices)');
45
+ if ((runResult.stderr || '').includes('No such file or directory'))
46
+ runResult.suggestedFixes.push('Verify ADB_PATH or that adb is installed');
47
+ }
48
+ return { runResult };
49
+ }