mobile-debug-mcp 0.12.3 → 0.12.5
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/dist/ios/manage.js +59 -21
- package/dist/ios/utils.js +49 -0
- package/dist/server.js +8 -8
- package/dist/tools/manage.js +84 -63
- package/docs/CHANGELOG.md +3 -0
- package/package.json +1 -1
- package/src/ios/manage.ts +51 -21
- package/src/ios/utils.ts +47 -0
- package/src/server.ts +8 -8
- package/src/tools/manage.ts +74 -54
- package/src/types.ts +1 -0
- package/test/unit/manage/mcp_disable_autodetect.test.ts +2 -1
- /package/docs/{TOOLS.md → tools/TOOLS.md} +0 -0
- /package/docs/{interact.md → tools/interact.md} +0 -0
- /package/docs/{manage.md → tools/manage.md} +0 -0
- /package/docs/{observe.md → tools/observe.md} +0 -0
package/dist/ios/manage.js
CHANGED
|
@@ -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,
|
|
7
|
-
|
|
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 =
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -264,15 +291,26 @@ export class iOSManage {
|
|
|
264
291
|
}
|
|
265
292
|
async startApp(bundleId, deviceId = "booted") {
|
|
266
293
|
validateBundleId(bundleId);
|
|
294
|
+
// Prepare instrumentation object upfront so it can be returned to callers
|
|
295
|
+
const instrumentation = { ts: new Date().toISOString(), action: 'startApp', cmd: 'xcrun', args: ['simctl', 'launch', deviceId, bundleId], cwd: process.cwd(), env: { PATH: process.env.PATH, XCRUN_PATH: process.env.XCRUN_PATH } };
|
|
267
296
|
try {
|
|
297
|
+
// Instrumentation: persist and emit to stderr for server logs
|
|
298
|
+
try {
|
|
299
|
+
await fs.appendFile('/tmp/mcp_startapp_instrument.log', JSON.stringify(instrumentation) + '\n');
|
|
300
|
+
}
|
|
301
|
+
catch (e) { }
|
|
302
|
+
try {
|
|
303
|
+
console.error('MCP-STARTAPP-EXEC', JSON.stringify(instrumentation));
|
|
304
|
+
}
|
|
305
|
+
catch (e) { }
|
|
268
306
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
269
307
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
270
|
-
return { device, appStarted: !!result.output, launchTimeMs: 1000 };
|
|
308
|
+
return { device, appStarted: !!result.output, launchTimeMs: 1000, instrumentation };
|
|
271
309
|
}
|
|
272
310
|
catch (e) {
|
|
273
311
|
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
274
312
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
275
|
-
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
313
|
+
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag, instrumentation };
|
|
276
314
|
}
|
|
277
315
|
}
|
|
278
316
|
async terminateApp(bundleId, deviceId = "booted") {
|
package/dist/ios/utils.js
CHANGED
|
@@ -96,7 +96,40 @@ export function validateBundleId(bundleId) {
|
|
|
96
96
|
}
|
|
97
97
|
export function execCommand(args, deviceId = "booted") {
|
|
98
98
|
return new Promise((resolve, reject) => {
|
|
99
|
+
// Instrumentation: append a JSON line with timestamp, command, args, cwd and selected env vars
|
|
100
|
+
try {
|
|
101
|
+
const mcpEnv = {};
|
|
102
|
+
for (const k of Object.keys(process.env || {})) {
|
|
103
|
+
if (k.startsWith('MCP_'))
|
|
104
|
+
mcpEnv[k] = process.env[k];
|
|
105
|
+
}
|
|
106
|
+
const instrument = {
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
command: getXcrunCmd(),
|
|
109
|
+
args,
|
|
110
|
+
cwd: process.cwd(),
|
|
111
|
+
env: {
|
|
112
|
+
PATH: process.env.PATH,
|
|
113
|
+
XCRUN_PATH: process.env.XCRUN_PATH,
|
|
114
|
+
...mcpEnv
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(instrument) + '\n');
|
|
119
|
+
}
|
|
120
|
+
catch (e) { }
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
// swallow instrumentation errors to avoid changing behavior
|
|
124
|
+
}
|
|
99
125
|
// Use spawn for better stream control and consistency with Android implementation
|
|
126
|
+
// Instrument: emit a JSON line to stderr so the MCP server stderr/stdout capture can record the exact command and env
|
|
127
|
+
try {
|
|
128
|
+
const instLine = JSON.stringify({ ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd(), PATH: process.env.PATH });
|
|
129
|
+
// Use stderr so it appears in server logs reliably
|
|
130
|
+
console.error('MCP-INSTRUMENT-EXEC', instLine);
|
|
131
|
+
}
|
|
132
|
+
catch (e) { }
|
|
100
133
|
const child = spawn(getXcrunCmd(), args);
|
|
101
134
|
let stdout = '';
|
|
102
135
|
let stderr = '';
|
|
@@ -110,6 +143,17 @@ export function execCommand(args, deviceId = "booted") {
|
|
|
110
143
|
stderr += data.toString();
|
|
111
144
|
});
|
|
112
145
|
}
|
|
146
|
+
// Additional instrumentation: write pid and env snapshot when child starts
|
|
147
|
+
try {
|
|
148
|
+
const pidInfo = { ts: new Date().toISOString(), childPid: (child.pid || null), invoked: getXcrunCmd(), args };
|
|
149
|
+
try {
|
|
150
|
+
require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(pidInfo) + '\n');
|
|
151
|
+
}
|
|
152
|
+
catch (e) { }
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
// ignore
|
|
156
|
+
}
|
|
113
157
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
|
|
114
158
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
|
|
115
159
|
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
|
|
@@ -133,6 +177,11 @@ export function execCommand(args, deviceId = "booted") {
|
|
|
133
177
|
});
|
|
134
178
|
}
|
|
135
179
|
export function execCommandWithDiagnostics(args, deviceId = "booted") {
|
|
180
|
+
try {
|
|
181
|
+
const syncInst = { ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd() };
|
|
182
|
+
require('fs').appendFileSync('/tmp/mcp_exec_instrument_sync.log', JSON.stringify(syncInst) + '\n');
|
|
183
|
+
}
|
|
184
|
+
catch (e) { }
|
|
136
185
|
// Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
|
|
137
186
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
|
|
138
187
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
|
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: "
|
|
123
|
-
projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "
|
|
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: "
|
|
137
|
-
projectType: { type: "string", enum: ["native", "kmp", "react-native", "flutter"], description: "
|
|
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
|
{
|
package/dist/tools/manage.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
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,
|
|
9
|
-
|
|
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 =
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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')
|
|
@@ -274,14 +298,20 @@ export class iOSManage {
|
|
|
274
298
|
|
|
275
299
|
async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
|
|
276
300
|
validateBundleId(bundleId)
|
|
301
|
+
// Prepare instrumentation object upfront so it can be returned to callers
|
|
302
|
+
const instrumentation = { ts: new Date().toISOString(), action: 'startApp', cmd: 'xcrun', args: ['simctl','launch', deviceId, bundleId], cwd: process.cwd(), env: { PATH: process.env.PATH, XCRUN_PATH: process.env.XCRUN_PATH } }
|
|
277
303
|
try {
|
|
304
|
+
// Instrumentation: persist and emit to stderr for server logs
|
|
305
|
+
try { await fs.appendFile('/tmp/mcp_startapp_instrument.log', JSON.stringify(instrumentation) + '\n') } catch (e) {}
|
|
306
|
+
try { console.error('MCP-STARTAPP-EXEC', JSON.stringify(instrumentation)) } catch (e) {}
|
|
307
|
+
|
|
278
308
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
279
309
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
280
|
-
return { device, appStarted: !!result.output, launchTimeMs: 1000 }
|
|
310
|
+
return { device, appStarted: !!result.output, launchTimeMs: 1000, instrumentation }
|
|
281
311
|
} catch (e:any) {
|
|
282
312
|
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
283
313
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
284
|
-
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
314
|
+
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag, instrumentation } as any
|
|
285
315
|
}
|
|
286
316
|
}
|
|
287
317
|
|
package/src/ios/utils.ts
CHANGED
|
@@ -88,7 +88,41 @@ export function validateBundleId(bundleId: string) {
|
|
|
88
88
|
|
|
89
89
|
export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
|
|
90
90
|
return new Promise((resolve, reject) => {
|
|
91
|
+
// Instrumentation: append a JSON line with timestamp, command, args, cwd and selected env vars
|
|
92
|
+
try {
|
|
93
|
+
const mcpEnv: Record<string,string|undefined> = {}
|
|
94
|
+
for (const k of Object.keys(process.env || {})) {
|
|
95
|
+
if (k.startsWith('MCP_')) mcpEnv[k] = process.env[k]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const instrument = {
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
command: getXcrunCmd(),
|
|
101
|
+
args,
|
|
102
|
+
cwd: process.cwd(),
|
|
103
|
+
env: {
|
|
104
|
+
PATH: process.env.PATH,
|
|
105
|
+
XCRUN_PATH: process.env.XCRUN_PATH,
|
|
106
|
+
...mcpEnv
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(instrument) + '\n')
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// swallow instrumentation errors to avoid changing behavior
|
|
116
|
+
}
|
|
117
|
+
|
|
91
118
|
// Use spawn for better stream control and consistency with Android implementation
|
|
119
|
+
// Instrument: emit a JSON line to stderr so the MCP server stderr/stdout capture can record the exact command and env
|
|
120
|
+
try {
|
|
121
|
+
const instLine = JSON.stringify({ ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd(), PATH: process.env.PATH })
|
|
122
|
+
// Use stderr so it appears in server logs reliably
|
|
123
|
+
console.error('MCP-INSTRUMENT-EXEC', instLine)
|
|
124
|
+
} catch (e) {}
|
|
125
|
+
|
|
92
126
|
const child = spawn(getXcrunCmd(), args)
|
|
93
127
|
|
|
94
128
|
let stdout = ''
|
|
@@ -106,6 +140,14 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
106
140
|
})
|
|
107
141
|
}
|
|
108
142
|
|
|
143
|
+
// Additional instrumentation: write pid and env snapshot when child starts
|
|
144
|
+
try {
|
|
145
|
+
const pidInfo = { ts: new Date().toISOString(), childPid: (child.pid || null), invoked: getXcrunCmd(), args }
|
|
146
|
+
try { require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(pidInfo) + '\n') } catch (e) {}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// ignore
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
|
|
110
152
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
|
|
111
153
|
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT // choose appropriate timeout
|
|
@@ -131,6 +173,11 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
131
173
|
}
|
|
132
174
|
|
|
133
175
|
export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
|
|
176
|
+
try {
|
|
177
|
+
const syncInst = { ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd() }
|
|
178
|
+
require('fs').appendFileSync('/tmp/mcp_exec_instrument_sync.log', JSON.stringify(syncInst) + '\n')
|
|
179
|
+
} catch (e) {}
|
|
180
|
+
|
|
134
181
|
// Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
|
|
135
182
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
|
|
136
183
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
|
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: "
|
|
142
|
-
projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "
|
|
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: "
|
|
156
|
-
projectType: { type: "string", enum: ["native","kmp","react-native","flutter"], description: "
|
|
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
|
|
package/src/tools/manage.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 } }
|
package/src/types.ts
CHANGED
|
@@ -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('
|
|
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
|