mobile-debug-mcp 0.12.3 → 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.
@@ -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 {
@@ -74,28 +96,33 @@ export class iOSManage {
74
96
  }
75
97
  // Prepare build flags and paths (support incremental builds)
76
98
  let buildArgs;
77
- let chosenScheme = null;
99
+ let chosenScheme = opts.scheme || null;
78
100
  // 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');
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`);
81
104
  const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4;
82
- const forceClean = process.env.MCP_FORCE_CLEAN === '1';
105
+ const forceClean = opts.forceClean || process.env.MCP_FORCE_CLEAN === '1';
83
106
  // ensure result dirs exist
84
107
  await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => { });
85
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(() => { });
86
111
  if (workspace) {
87
112
  const workspacePath = path.join(projectRootDir, workspace);
88
- chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
113
+ if (!chosenScheme)
114
+ chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir);
89
115
  const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '');
90
116
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
91
117
  }
92
118
  else {
93
119
  const projectPathFull = path.join(projectRootDir, proj);
94
- chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir);
120
+ if (!chosenScheme)
121
+ chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir);
95
122
  const scheme = chosenScheme || proj.replace(/\.xcodeproj$/, '');
96
123
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
97
124
  }
98
- // Insert clean if explicitly requested via env
125
+ // Insert clean if explicitly requested via env or opts
99
126
  if (forceClean) {
100
127
  const idx = buildArgs.indexOf('build');
101
128
  if (idx >= 0)
package/dist/server.js CHANGED
@@ -115,30 +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." },
123
- projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
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)." },
124
124
  appPath: { type: "string", description: "Path to APK, .app, .ipa, or project directory" },
125
125
  deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
126
126
  },
127
- required: ["appPath"]
127
+ required: ["platform", "projectType", "appPath"]
128
128
  }
129
129
  },
130
130
  {
131
131
  name: "build_app",
132
- 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.",
133
133
  inputSchema: {
134
134
  type: "object",
135
135
  properties: {
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
+ 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)." },
138
138
  projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
139
139
  variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
140
140
  },
141
- required: ["projectPath"]
141
+ required: ["platform", "projectType", "projectPath"]
142
142
  }
143
143
  },
144
144
  {
@@ -7,18 +7,52 @@ import { findApk } from '../android/utils.js';
7
7
  import { findAppBundle } from '../ios/utils.js';
8
8
  import { execSync } from 'child_process';
9
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
+ }
10
45
  try {
11
46
  const stat = await fs.stat(projectPath).catch(() => null);
12
47
  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)));
48
+ const { ios: hasIos, android: hasAndroid } = await scan(projectPath, 3);
16
49
  if (hasIos && !hasAndroid)
17
50
  return 'ios';
18
51
  if (hasAndroid && !hasIos)
19
52
  return 'android';
20
53
  if (hasIos && hasAndroid)
21
54
  return 'ambiguous';
55
+ // no explicit markers found
22
56
  return 'unknown';
23
57
  }
24
58
  else {
@@ -50,10 +84,7 @@ export class ToolsManage {
50
84
  }
51
85
  static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
52
86
  const ios = new iOSManage();
53
- // silence unused param lints
54
- void _workspace;
55
- void _project;
56
- void _scheme;
87
+ // Use provided options rather than env-only; still set env fallbacks for downstream tools
57
88
  if (derivedDataPath)
58
89
  process.env.MCP_DERIVED_DATA = derivedDataPath;
59
90
  if (typeof buildJobs === 'number')
@@ -62,7 +93,23 @@ export class ToolsManage {
62
93
  process.env.MCP_FORCE_CLEAN_IOS = '1';
63
94
  if (destinationUDID)
64
95
  process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
65
- const artifact = await ios.build(projectPath);
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);
66
113
  return artifact;
67
114
  }
68
115
  static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
@@ -160,52 +207,11 @@ export class ToolsManage {
160
207
  }
161
208
  }
162
209
  static async installAppHandler({ platform, appPath, deviceId, projectType }) {
163
- // Use projectType hint to influence platform detection when explicit platform is not provided
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
- }
176
- try {
177
- const stat = await fs.stat(appPath).catch(() => null);
178
- if (stat && stat.isDirectory()) {
179
- // If the directory itself looks like an .app bundle, treat as iOS
180
- if (appPath.endsWith('.app')) {
181
- chosenPlatform = 'ios';
182
- }
183
- else {
184
- const files = (await fs.readdir(appPath).catch(() => []));
185
- if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
186
- chosenPlatform = 'ios';
187
- }
188
- 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)))) {
189
- chosenPlatform = 'android';
190
- }
191
- else {
192
- chosenPlatform = 'android';
193
- }
194
- }
195
- }
196
- else if (typeof appPath === 'string') {
197
- const ext = path.extname(appPath).toLowerCase();
198
- if (ext === '.apk')
199
- chosenPlatform = 'android';
200
- else if (ext === '.ipa' || ext === '.app')
201
- chosenPlatform = 'ios';
202
- else
203
- chosenPlatform = 'android';
204
- }
205
- }
206
- catch {
207
- chosenPlatform = 'android';
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).');
208
213
  }
214
+ const chosenPlatform = platform;
209
215
  if (chosenPlatform === 'android') {
210
216
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
211
217
  const androidRun = new AndroidManage();
@@ -264,21 +270,36 @@ export class ToolsManage {
264
270
  const pushEvent = (obj) => events.push(JSON.stringify(obj));
265
271
  const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
266
272
  void effectiveTimeout;
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
+ }
267
278
  // determine platform if not provided by inspecting path or projectType hint
268
279
  let chosenPlatform = platform;
269
280
  try {
270
281
  if (!chosenPlatform) {
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' });
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') {
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
+ }
280
296
  }
281
297
  else {
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
+ }
282
303
  const det = await detectProjectPlatform(projectPath);
283
304
  if (det === 'ios' || det === 'android') {
284
305
  chosenPlatform = det;
@@ -318,7 +339,7 @@ export class ToolsManage {
318
339
  pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
319
340
  let installRes;
320
341
  try {
321
- installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId });
342
+ installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId, projectType });
322
343
  if (installRes && installRes.installed === true) {
323
344
  pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
324
345
  return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.12.4]
6
+ - Made projectType and platform mandatory
7
+
5
8
  ## [0.12.3]
6
9
  - Now supports native and cross platform development platforms for building
7
10
  - Add MCP_DISABLE_AUTODETECT env var to require explicit platform/projectType for deterministic agent runs. When set to 1, build/install handlers will fail if platform is not provided.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ios/manage.ts CHANGED
@@ -5,11 +5,15 @@ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validate
5
5
  import path from "path"
6
6
 
7
7
  export class iOSManage {
8
- async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string, diagnostics?: any }> {
9
- void _variant
8
+ 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 }> {
9
+ // Support legacy variant string as second arg
10
+ let opts: any = {}
11
+ if (typeof optsOrVariant === 'string') opts.variant = optsOrVariant
12
+ else opts = optsOrVariant || {}
13
+
10
14
  try {
11
15
  // Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
12
- async function findProject(root: string, maxDepth = 3): Promise<{ dir: string, workspace?: string, proj?: string } | null> {
16
+ async function findProject(root: string, maxDepth = 4): Promise<{ dir: string, workspace?: string, proj?: string } | null> {
13
17
  try {
14
18
  const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
15
19
  for (const e of ents) {
@@ -36,14 +40,31 @@ export class iOSManage {
36
40
 
37
41
  // Resolve projectPath to an absolute path to avoid cwd-relative resolution issues
38
42
  const absProjectPath = path.resolve(projectPath)
39
- const projectInfo = await findProject(absProjectPath, 3)
40
- if (!projectInfo) return { error: 'No Xcode project or workspace found' }
41
- const projectRootDir = projectInfo.dir || absProjectPath
42
- const workspace = projectInfo.workspace
43
- const proj = projectInfo.proj
44
-
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 || ''
43
+
44
+ // If caller supplied explicit workspace/project, prefer those and set projectRootDir accordingly
45
+ let projectRootDir = absProjectPath
46
+ let workspace: string | undefined = opts.workspace
47
+ let proj: string | undefined = opts.project
48
+
49
+ if (workspace) {
50
+ // normalize workspace path and set root to its parent
51
+ workspace = path.isAbsolute(workspace) ? workspace : path.join(absProjectPath, workspace)
52
+ projectRootDir = path.dirname(workspace)
53
+ workspace = path.basename(workspace)
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
+ } else {
59
+ const projectInfo = await findProject(absProjectPath, 4)
60
+ if (!projectInfo) return { error: 'No Xcode project or workspace found' }
61
+ projectRootDir = projectInfo.dir || absProjectPath
62
+ workspace = projectInfo.workspace
63
+ proj = projectInfo.proj
64
+ }
65
+
66
+ // Determine destination: prefer explicit option, then env var, otherwise use booted simulator UDID
67
+ let destinationUDID = opts.destinationUDID || process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || ''
47
68
  if (!destinationUDID) {
48
69
  try {
49
70
  const meta = await getIOSDeviceMetadata('booted')
@@ -52,7 +73,7 @@ export class iOSManage {
52
73
  }
53
74
 
54
75
  // Determine xcode command early so it can be used when detecting schemes
55
- const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
76
+ const xcodeCmd = opts.xcodeCmd || process.env.XCODEBUILD_PATH || 'xcodebuild'
56
77
 
57
78
  // Determine available schemes by querying xcodebuild -list rather than guessing
58
79
  async function detectScheme(xcodeCmdInner: string, workspacePath?: string, projectPathFull?: string, cwd?: string): Promise<string | null> {
@@ -73,31 +94,34 @@ export class iOSManage {
73
94
 
74
95
  // Prepare build flags and paths (support incremental builds)
75
96
  let buildArgs: string[]
76
- let chosenScheme: string | null = null
97
+ let chosenScheme: string | null = opts.scheme || null
77
98
 
78
99
  // 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')
100
+ const derivedDataPath = opts.derivedDataPath || process.env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData')
101
+ // Use unique result bundle path by default to avoid collisions
102
+ const resultBundlePath = process.env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`)
81
103
  const xcodeJobs = parseInt(process.env.MCP_XCODE_JOBS || '', 10) || 4
82
- const forceClean = process.env.MCP_FORCE_CLEAN === '1'
104
+ const forceClean = opts.forceClean || process.env.MCP_FORCE_CLEAN === '1'
83
105
 
84
106
  // ensure result dirs exist
85
107
  await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => {})
86
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(() => {})
87
111
 
88
112
  if (workspace) {
89
113
  const workspacePath = path.join(projectRootDir, workspace)
90
- chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir)
114
+ if (!chosenScheme) chosenScheme = await detectScheme(xcodeCmd, workspacePath, undefined, projectRootDir)
91
115
  const scheme = chosenScheme || workspace.replace(/\.xcworkspace$/, '')
92
116
  buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
93
117
  } else {
94
118
  const projectPathFull = path.join(projectRootDir, proj!)
95
- chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir)
119
+ if (!chosenScheme) chosenScheme = await detectScheme(xcodeCmd, undefined, projectPathFull, projectRootDir)
96
120
  const scheme = chosenScheme || proj!.replace(/\.xcodeproj$/, '')
97
121
  buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
98
122
  }
99
123
 
100
- // Insert clean if explicitly requested via env
124
+ // Insert clean if explicitly requested via env or opts
101
125
  if (forceClean) {
102
126
  const idx = buildArgs.indexOf('build')
103
127
  if (idx >= 0) buildArgs.splice(idx, 0, 'clean')
package/src/server.ts CHANGED
@@ -134,30 +134,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
134
134
  },
135
135
  {
136
136
  name: "install_app",
137
- description: "Install an app on Android or iOS. Accepts a built binary (apk/.ipa/.app) or a project directory to build then install.",
137
+ 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.",
138
138
  inputSchema: {
139
139
  type: "object",
140
140
  properties: {
141
- platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from appPath/project files." },
142
- projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
141
+ platform: { type: "string", enum: ["android", "ios"], description: "Platform to install to (required)." },
142
+ projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "Project type to guide build/install tool selection (required)." },
143
143
  appPath: { type: "string", description: "Path to APK, .app, .ipa, or project directory" },
144
144
  deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
145
145
  },
146
- required: ["appPath"]
146
+ required: ["platform", "projectType", "appPath"]
147
147
  }
148
148
  },
149
149
  {
150
150
  name: "build_app",
151
- description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
151
+ description: "Build a project for Android or iOS and return the built artifact path. Does not install. platform and projectType are required.",
152
152
  inputSchema: {
153
153
  type: "object",
154
154
  properties: {
155
- platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
156
- projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "Optional project type to guide build tool selection (e.g., kmp, react-native)." },
155
+ platform: { type: "string", enum: ["android", "ios"], description: "Platform to build for (required)." },
156
+ projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "Project type to guide build tool selection (required)." },
157
157
  projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
158
158
  variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
159
159
  },
160
- required: ["projectPath"]
160
+ required: ["platform", "projectType", "projectPath"]
161
161
  }
162
162
  },
163
163
 
@@ -9,15 +9,42 @@ import { execSync } from 'child_process'
9
9
  import type { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
10
10
 
11
11
  export async function detectProjectPlatform(projectPath: string): Promise<'ios'|'android'|'ambiguous'|'unknown'> {
12
+ // Recursively scan up to a limited depth for platform markers to avoid mis-detection
13
+ async function scan(dir: string, depth = 3): Promise<{ ios: boolean, android: boolean }>{
14
+ const res = { ios: false, android: false }
15
+ try {
16
+ const ents = await fs.readdir(dir).catch(() => [])
17
+ for (const e of ents) {
18
+ if (e.endsWith('.xcworkspace') || e.endsWith('.xcodeproj')) res.ios = true
19
+ if (e === 'gradlew' || e === 'build.gradle' || e === 'settings.gradle') res.android = true
20
+ if (res.ios && res.android) return res
21
+ }
22
+
23
+ if (depth <= 0) return res
24
+ for (const e of ents) {
25
+ try {
26
+ const full = path.join(dir, e)
27
+ const st = await fs.stat(full).catch(() => null)
28
+ if (st && st.isDirectory()) {
29
+ const child = await scan(full, depth - 1)
30
+ if (child.ios) res.ios = true
31
+ if (child.android) res.android = true
32
+ if (res.ios && res.android) return res
33
+ }
34
+ } catch {}
35
+ }
36
+ } catch {}
37
+ return res
38
+ }
39
+
12
40
  try {
13
41
  const stat = await fs.stat(projectPath).catch(() => null)
14
42
  if (stat && stat.isDirectory()) {
15
- const files = (await fs.readdir(projectPath).catch(() => [])) as string[]
16
- const hasIos = files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))
17
- 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)))
43
+ const { ios: hasIos, android: hasAndroid } = await scan(projectPath, 3)
18
44
  if (hasIos && !hasAndroid) return 'ios'
19
45
  if (hasAndroid && !hasIos) return 'android'
20
46
  if (hasIos && hasAndroid) return 'ambiguous'
47
+ // no explicit markers found
21
48
  return 'unknown'
22
49
  } else {
23
50
  const ext = path.extname(projectPath).toLowerCase()
@@ -44,13 +71,23 @@ export class ToolsManage {
44
71
 
45
72
  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 }) {
46
73
  const ios = new iOSManage()
47
- // silence unused param lints
48
- void _workspace; void _project; void _scheme;
74
+ // Use provided options rather than env-only; still set env fallbacks for downstream tools
49
75
  if (derivedDataPath) process.env.MCP_DERIVED_DATA = derivedDataPath
50
76
  if (typeof buildJobs === 'number') process.env.MCP_BUILD_JOBS = String(buildJobs)
51
77
  if (forceClean) process.env.MCP_FORCE_CLEAN_IOS = '1'
52
78
  if (destinationUDID) process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID
53
- const artifact = await (ios as any).build(projectPath)
79
+
80
+ const opts: any = {}
81
+ if (_workspace) opts.workspace = _workspace
82
+ if (_project) opts.project = _project
83
+ if (_scheme) opts.scheme = _scheme
84
+ if (destinationUDID) opts.destinationUDID = destinationUDID
85
+ if (derivedDataPath) opts.derivedDataPath = derivedDataPath
86
+ if (forceClean) opts.forceClean = forceClean
87
+ // prefer explicit xcodebuild path from env
88
+ if (process.env.XCODEBUILD_PATH) opts.xcodeCmd = process.env.XCODEBUILD_PATH
89
+
90
+ const artifact = await (ios as any).build(projectPath, opts)
54
91
  return artifact
55
92
  }
56
93
 
@@ -147,45 +184,13 @@ export class ToolsManage {
147
184
  }
148
185
  }
149
186
 
150
- static async installAppHandler({ platform, appPath, deviceId, projectType }: { platform?: 'android' | 'ios', appPath: string, deviceId?: string, projectType?: 'native' | 'kmp' | 'react-native' | 'flutter' }): Promise<InstallAppResponse> {
151
- // Use projectType hint to influence platform detection when explicit platform is not provided
152
- let chosenPlatform: 'android'|'ios'|undefined = platform
153
- if (!chosenPlatform && projectType) {
154
- // Heuristic defaults: KMP, React Native and Flutter commonly target Android by default in CI
155
- if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
156
- chosenPlatform = 'android'
157
- console.debug('[manage] projectType hint -> selecting android by default for', projectType)
158
- } else if (projectType === 'native' || projectType === 'ios') {
159
- chosenPlatform = 'ios'
160
- console.debug('[manage] projectType hint -> selecting ios by default for', projectType)
161
- }
187
+ static async installAppHandler({ platform, appPath, deviceId, projectType }: { platform: 'android' | 'ios', appPath: string, deviceId?: string, projectType: 'native' | 'kmp' | 'react-native' | 'flutter' }): Promise<InstallAppResponse> {
188
+ // Enforce explicit platform and projectType: both are mandatory to avoid ambiguity
189
+ if (!platform || !projectType) {
190
+ throw new Error('Both platform and projectType parameters are required (platform: ios|android, projectType: native|kmp|react-native|flutter).')
162
191
  }
163
192
 
164
- try {
165
- const stat = await fs.stat(appPath).catch(() => null)
166
- if (stat && stat.isDirectory()) {
167
- // If the directory itself looks like an .app bundle, treat as iOS
168
- if (appPath.endsWith('.app')) {
169
- chosenPlatform = 'ios'
170
- } else {
171
- const files = (await fs.readdir(appPath).catch(() => [])) as string[]
172
- if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
173
- chosenPlatform = 'ios'
174
- } 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)))) {
175
- chosenPlatform = 'android'
176
- } else {
177
- chosenPlatform = 'android'
178
- }
179
- }
180
- } else if (typeof appPath === 'string') {
181
- const ext = path.extname(appPath).toLowerCase()
182
- if (ext === '.apk') chosenPlatform = 'android'
183
- else if (ext === '.ipa' || ext === '.app') chosenPlatform = 'ios'
184
- else chosenPlatform = 'android'
185
- }
186
- } catch {
187
- chosenPlatform = 'android'
188
- }
193
+ const chosenPlatform: 'android'|'ios' = platform
189
194
 
190
195
  if (chosenPlatform === 'android') {
191
196
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
@@ -240,26 +245,41 @@ export class ToolsManage {
240
245
  }
241
246
  }
242
247
 
243
- static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }: { platform?: 'android' | 'ios', projectPath: string, deviceId?: string, timeout?: number, projectType?: 'native' | 'kmp' | 'react-native' | 'flutter' }) {
248
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout, projectType }: { platform: 'android' | 'ios', projectPath: string, deviceId?: string, timeout?: number, projectType: 'native' | 'kmp' | 'react-native' | 'flutter' }) {
244
249
  const events: string[] = []
245
250
  const pushEvent = (obj: any) => events.push(JSON.stringify(obj))
246
251
  const effectiveTimeout = timeout ?? 180000 // reserved for future streaming/timeouts
247
252
  void effectiveTimeout
248
253
 
254
+ // Require explicit platform and projectType to avoid ambiguous autodetection
255
+ if (!platform || !projectType) {
256
+ pushEvent({ type: 'build', status: 'failed', error: 'Both platform and projectType parameters are required.' })
257
+ 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).' } }
258
+ }
259
+
249
260
  // determine platform if not provided by inspecting path or projectType hint
250
261
  let chosenPlatform = platform
251
262
  try {
252
263
  if (!chosenPlatform) {
253
- // If autodetect is disabled, require explicit platform or projectType
254
- if (process.env.MCP_DISABLE_AUTODETECT === '1') {
255
- pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' })
256
- return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } }
257
- }
258
- // If caller indicated KMP, prefer android by default (most KMP modul8 setups target Android)
259
- if (projectType === 'kmp') {
260
- chosenPlatform = 'android'
261
- pushEvent({ type: 'build', status: 'info', message: 'projectType=kmp -> selecting android platform by default' })
264
+ // If caller provided projectType, respect it as a hard override and map to platform
265
+ if (projectType) {
266
+ if (projectType === 'kmp' || projectType === 'react-native' || projectType === 'flutter') {
267
+ chosenPlatform = 'android'
268
+ pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing android platform` })
269
+ } else if (projectType === 'native' || projectType === 'ios') {
270
+ chosenPlatform = 'ios'
271
+ pushEvent({ type: 'build', status: 'info', message: `projectType=${projectType} -> forcing ios platform` })
272
+ } else {
273
+ pushEvent({ type: 'build', status: 'failed', error: `Unknown projectType: ${projectType}` })
274
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: `Unknown projectType: ${projectType}` } }
275
+ }
262
276
  } else {
277
+ // If autodetect is disabled, require explicit platform or projectType
278
+ if (process.env.MCP_DISABLE_AUTODETECT === '1') {
279
+ pushEvent({ type: 'build', status: 'failed', error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType' })
280
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: 'MCP_DISABLE_AUTODETECT=1 requires explicit platform or projectType (ios|android).' } }
281
+ }
282
+
263
283
  const det = await detectProjectPlatform(projectPath)
264
284
  if (det === 'ios' || det === 'android') {
265
285
  chosenPlatform = det
@@ -298,7 +318,7 @@ export class ToolsManage {
298
318
  pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId })
299
319
  let installRes: any
300
320
  try {
301
- installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform as any, appPath: artifact, deviceId })
321
+ installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform as any, appPath: artifact, deviceId, projectType })
302
322
  if (installRes && installRes.installed === true) {
303
323
  pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device })
304
324
  return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } }
@@ -18,10 +18,11 @@ export async function run() {
18
18
  const { ToolsManage } = await import('../../../src/tools/manage.js')
19
19
 
20
20
  try {
21
+ // platform and projectType are now mandatory; calling without them should return a missing-params error
21
22
  const res = await ToolsManage.buildAndInstallHandler({ projectPath: both })
22
23
  console.log('result:', res.result)
23
24
  assert.strictEqual(res.result.success, false)
24
- assert.ok(String(res.result.error).includes('MCP_DISABLE_AUTODETECT'), 'Expected error to mention MCP_DISABLE_AUTODETECT')
25
+ assert.ok(String(res.result.error).includes('Both platform and projectType parameters are required'), 'Expected missing-params error')
25
26
  console.log('mcp_disable_autodetect test passed')
26
27
  } finally {
27
28
  if (orig === undefined) delete process.env.MCP_DISABLE_AUTODETECT
File without changes
File without changes
File without changes
File without changes