mobile-debug-mcp 0.19.2 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/manage/android.js +5 -2
- package/dist/server.js +14 -48
- package/dist/system/android.js +78 -0
- package/dist/system/gradle.js +101 -0
- package/dist/system/index.js +32 -0
- package/dist/system/ios.js +38 -0
- package/dist/utils/android/utils.js +33 -1
- package/docs/CHANGELOG.md +9 -0
- package/docs/tools/TOOLS.md +1 -0
- package/docs/tools/system.md +48 -0
- package/package.json +1 -1
- package/src/manage/android.ts +5 -1
- package/src/server.ts +15 -41
- package/src/system/android.ts +62 -0
- package/src/system/gradle.ts +98 -0
- package/src/system/index.ts +33 -0
- package/src/system/ios.ts +28 -0
- package/src/utils/android/utils.ts +27 -1
- package/test/system/adb_version.test.ts +25 -0
- package/test/system/get_system_status.test.ts +52 -0
- package/test/system/system_status.test.ts +109 -0
package/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
|
|
4
4
|
|
|
5
|
-
> **Note:**
|
|
5
|
+
> **Note:**
|
|
6
|
+
> * iOS only tested on simulator.
|
|
7
|
+
> * Flutter iOS projects not fetching logs
|
|
8
|
+
> * React native not tested
|
|
6
9
|
|
|
7
10
|
## Requirements
|
|
8
11
|
|
|
@@ -28,10 +31,9 @@ You will need to add ADB_PATH for Android and XCRUN_PATH and IDB_PATH for iOS.
|
|
|
28
31
|
|
|
29
32
|
## Usage
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
|
|
34
|
+
Examples:
|
|
35
|
+
* I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
|
|
36
|
+
* Add a button, hook into the repository and confirm API request successful
|
|
35
37
|
|
|
36
38
|
## Docs
|
|
37
39
|
|
package/dist/manage/android.js
CHANGED
|
@@ -64,10 +64,13 @@ export class AndroidManage {
|
|
|
64
64
|
const spawnOpts = { cwd: apkPath, env };
|
|
65
65
|
if (useWrapper) {
|
|
66
66
|
await fs.chmod(wrapperPath, 0o755).catch(() => { });
|
|
67
|
+
// Run wrapper directly to avoid shell splitting of args
|
|
68
|
+
spawnOpts.shell = false;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Execute gradle directly without a shell so paths with spaces are preserved
|
|
67
72
|
spawnOpts.shell = false;
|
|
68
73
|
}
|
|
69
|
-
else
|
|
70
|
-
spawnOpts.shell = true;
|
|
71
74
|
const proc = spawn(execCmd, gradleArgs, spawnOpts);
|
|
72
75
|
let stderr = '';
|
|
73
76
|
await new Promise((resolve, reject) => {
|
package/dist/server.js
CHANGED
|
@@ -7,10 +7,6 @@ import { ToolsInteract } from './interact/index.js';
|
|
|
7
7
|
import { ToolsObserve } from './observe/index.js';
|
|
8
8
|
import { AndroidManage } from './manage/index.js';
|
|
9
9
|
import { iOSManage } from './manage/index.js';
|
|
10
|
-
import { ensureAdbAvailable } from './utils/android/utils.js';
|
|
11
|
-
import { getIdbCmd, isIDBInstalled } from './utils/ios/utils.js';
|
|
12
|
-
import { getXcrunCmd } from './utils/ios/utils.js';
|
|
13
|
-
import { execSync } from 'child_process';
|
|
14
10
|
const server = new Server({
|
|
15
11
|
name: "mobile-debug-mcp",
|
|
16
12
|
version: "0.7.0"
|
|
@@ -19,50 +15,11 @@ const server = new Server({
|
|
|
19
15
|
tools: {}
|
|
20
16
|
}
|
|
21
17
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
console.debug('[startup] adb available:', adbCheck.adbCmd, adbCheck.version);
|
|
28
|
-
else
|
|
29
|
-
console.warn('[startup] adb not available or failed to run:', adbCheck.adbCmd, adbCheck.error);
|
|
30
|
-
}
|
|
31
|
-
catch (e) {
|
|
32
|
-
if (e instanceof Error) {
|
|
33
|
-
console.warn('[startup] error during adb healthcheck:', e.message);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
console.warn('[startup] error during adb healthcheck:', String(e));
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
// Check idb availability (non-fatal)
|
|
40
|
-
try {
|
|
41
|
-
const idbInstalled = await isIDBInstalled();
|
|
42
|
-
const idbCmd = getIdbCmd();
|
|
43
|
-
if (idbInstalled)
|
|
44
|
-
console.debug('[startup] idb available:', idbCmd);
|
|
45
|
-
else
|
|
46
|
-
console.debug('[startup] idb not available or failed to run:', idbCmd);
|
|
47
|
-
}
|
|
48
|
-
catch (e) {
|
|
49
|
-
console.warn('[startup] error during idb healthcheck:', e instanceof Error ? e.message : String(e));
|
|
50
|
-
}
|
|
51
|
-
// Check xcrun availability (non-fatal)
|
|
52
|
-
try {
|
|
53
|
-
const xcrun = getXcrunCmd();
|
|
54
|
-
try {
|
|
55
|
-
const out = execSync(`${xcrun} --version`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
56
|
-
console.debug('[startup] xcrun available:', xcrun, out.split('\n')[0]);
|
|
57
|
-
}
|
|
58
|
-
catch (err) {
|
|
59
|
-
console.warn('[startup] xcrun not available or failed to run:', xcrun, err instanceof Error ? err.message : String(err));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
catch (e) {
|
|
63
|
-
console.warn('[startup] error during xcrun healthcheck:', e instanceof Error ? e.message : String(e));
|
|
64
|
-
}
|
|
65
|
-
})();
|
|
18
|
+
import { getSystemStatus } from './system/index.js';
|
|
19
|
+
// Run a quick startup healthcheck (non-fatal) by calling getSystemStatus directly and log a short summary
|
|
20
|
+
getSystemStatus().then(res => {
|
|
21
|
+
console.debug('[startup] system status summary:', { adb: res.adbAvailable, ios: res.iosAvailable, devices: res.devices, iosDevices: res.iosDevices });
|
|
22
|
+
}).catch(e => console.warn('[startup] healthcheck failed:', e instanceof Error ? e.message : String(e)));
|
|
66
23
|
function wrapResponse(data) {
|
|
67
24
|
return {
|
|
68
25
|
content: [{
|
|
@@ -225,6 +182,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
225
182
|
}
|
|
226
183
|
}
|
|
227
184
|
},
|
|
185
|
+
{
|
|
186
|
+
name: "get_system_status",
|
|
187
|
+
description: "Quick healthcheck of local mobile debugging environment (adb, devices, logs, env, iOS).",
|
|
188
|
+
inputSchema: { type: "object", properties: {} }
|
|
189
|
+
},
|
|
228
190
|
{
|
|
229
191
|
name: "capture_screenshot",
|
|
230
192
|
description: "Capture a screenshot from an Android device or iOS simulator. Returns device metadata and the screenshot image.",
|
|
@@ -625,6 +587,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
625
587
|
const res = await ToolsManage.listDevicesHandler({ platform, appId });
|
|
626
588
|
return wrapResponse(res);
|
|
627
589
|
}
|
|
590
|
+
if (name === "get_system_status") {
|
|
591
|
+
const result = await getSystemStatus();
|
|
592
|
+
return wrapResponse(result);
|
|
593
|
+
}
|
|
628
594
|
if (name === "capture_screenshot") {
|
|
629
595
|
const { platform, deviceId } = args;
|
|
630
596
|
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { ensureAdbAvailable } from '../utils/android/utils.js';
|
|
3
|
+
export async function checkAndroid() {
|
|
4
|
+
const issues = [];
|
|
5
|
+
let adbAvailable = false;
|
|
6
|
+
let adbVersion = '';
|
|
7
|
+
let devices = 0;
|
|
8
|
+
let deviceStates = '';
|
|
9
|
+
let logsAvailable = false;
|
|
10
|
+
let envValid = false;
|
|
11
|
+
let appInstalled = undefined;
|
|
12
|
+
try {
|
|
13
|
+
const adbCheck = ensureAdbAvailable();
|
|
14
|
+
const adbCmd = adbCheck.adbCmd || 'adb';
|
|
15
|
+
adbAvailable = !!adbCheck.ok;
|
|
16
|
+
adbVersion = (adbCheck.version || '').toString().split('\n')[0];
|
|
17
|
+
if (!adbAvailable)
|
|
18
|
+
issues.push('ADB not available');
|
|
19
|
+
try {
|
|
20
|
+
const out = execSync(`${adbCmd} devices -l`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
21
|
+
const lines = out.split('\n').map(l => l.trim()).filter(Boolean);
|
|
22
|
+
const deviceLines = lines.filter(l => !l.startsWith('List of devices'));
|
|
23
|
+
const stateCounts = {};
|
|
24
|
+
for (const l of deviceLines) {
|
|
25
|
+
const parts = l.split(/\s+/);
|
|
26
|
+
const state = parts[1] || '';
|
|
27
|
+
stateCounts[state] = (stateCounts[state] || 0) + 1;
|
|
28
|
+
}
|
|
29
|
+
devices = deviceLines.length;
|
|
30
|
+
const parts = Object.entries(stateCounts).map(([k, v]) => `${v} ${k}`);
|
|
31
|
+
deviceStates = parts.join(', ');
|
|
32
|
+
if (devices === 0)
|
|
33
|
+
issues.push('No Android devices connected');
|
|
34
|
+
if (stateCounts['unauthorized'])
|
|
35
|
+
issues.push(`${stateCounts['unauthorized']} device(s) unauthorized`);
|
|
36
|
+
if (stateCounts['offline'])
|
|
37
|
+
issues.push(`${stateCounts['offline']} device(s) offline`);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
console.debug('[get_system_status] adb devices failed: ' + String(e));
|
|
41
|
+
issues.push('Failed to list Android devices');
|
|
42
|
+
}
|
|
43
|
+
if (adbAvailable && devices > 0) {
|
|
44
|
+
try {
|
|
45
|
+
const lo = execSync(`${adbCmd} logcat -d -t 1`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
46
|
+
logsAvailable = !!lo;
|
|
47
|
+
if (!logsAvailable)
|
|
48
|
+
issues.push('Log access failed');
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
logsAvailable = false;
|
|
52
|
+
console.debug('[get_system_status] logcat check failed: ' + String(e));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME;
|
|
56
|
+
envValid = !!sdkRoot || (adbAvailable === true && !!(adbCheck.adbCmd && adbCheck.adbCmd !== 'adb'));
|
|
57
|
+
if (!envValid)
|
|
58
|
+
issues.push('ANDROID_SDK_ROOT/ANDROID_HOME missing and adb not found in PATH');
|
|
59
|
+
const pkg = process.env.MCP_TARGET_PACKAGE || process.env.MCP_TARGET_APP_ID;
|
|
60
|
+
if (pkg && adbAvailable && devices > 0) {
|
|
61
|
+
try {
|
|
62
|
+
const pm = execSync(`${adbCmd} shell pm path ${pkg}`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
63
|
+
appInstalled = (pm || '').includes('package:');
|
|
64
|
+
if (!appInstalled)
|
|
65
|
+
issues.push(`App ${pkg} not installed on devices`);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
appInstalled = false;
|
|
69
|
+
console.debug('[get_system_status] pm check failed: ' + String(e));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
console.debug('[get_system_status] adb availability check failed: ' + String(e));
|
|
75
|
+
issues.push('ADB check failed');
|
|
76
|
+
}
|
|
77
|
+
return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, issues };
|
|
78
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
function readPropertiesFile(p) {
|
|
5
|
+
try {
|
|
6
|
+
const txt = readFileSync(p, { encoding: 'utf8' });
|
|
7
|
+
const lines = String(txt).split(/\r?\n/);
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const l of lines) {
|
|
10
|
+
const trimmed = l.trim();
|
|
11
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
12
|
+
continue;
|
|
13
|
+
const idx = trimmed.indexOf('=');
|
|
14
|
+
if (idx === -1)
|
|
15
|
+
continue;
|
|
16
|
+
const k = trimmed.substring(0, idx).trim();
|
|
17
|
+
const v = trimmed.substring(idx + 1).trim();
|
|
18
|
+
out[k] = v;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function javaBinExists(p) {
|
|
27
|
+
if (!p)
|
|
28
|
+
return false;
|
|
29
|
+
try {
|
|
30
|
+
const javaPath = path.join(p, 'bin', 'java');
|
|
31
|
+
if (existsSync(javaPath))
|
|
32
|
+
return true;
|
|
33
|
+
const alt = path.join(p, 'Contents', 'Home', 'bin', 'java');
|
|
34
|
+
if (existsSync(alt))
|
|
35
|
+
return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function checkGradle() {
|
|
43
|
+
const issues = [];
|
|
44
|
+
const filesChecked = [];
|
|
45
|
+
const suggestedFixes = [];
|
|
46
|
+
let gradleJavaHome;
|
|
47
|
+
// 1) explicit env
|
|
48
|
+
if (process.env.GRADLE_JAVA_HOME) {
|
|
49
|
+
gradleJavaHome = process.env.GRADLE_JAVA_HOME;
|
|
50
|
+
if (!javaBinExists(gradleJavaHome)) {
|
|
51
|
+
issues.push(`GRADLE_JAVA_HOME is set to '${gradleJavaHome}' but no java binary was found there`);
|
|
52
|
+
suggestedFixes.push('Unset GRADLE_JAVA_HOME or point it to a valid JDK (e.g., /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home)');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 2) user gradle.properties
|
|
56
|
+
const gradleUserHome = process.env.GRADLE_USER_HOME || path.join(os.homedir(), '.gradle');
|
|
57
|
+
const userProps = path.join(gradleUserHome, 'gradle.properties');
|
|
58
|
+
filesChecked.push(userProps);
|
|
59
|
+
try {
|
|
60
|
+
const props = readPropertiesFile(userProps);
|
|
61
|
+
if (props['org.gradle.java.home']) {
|
|
62
|
+
const p = props['org.gradle.java.home'];
|
|
63
|
+
gradleJavaHome = gradleJavaHome || p;
|
|
64
|
+
if (!javaBinExists(p)) {
|
|
65
|
+
issues.push(`org.gradle.java.home in ${userProps} points to '${p}' which does not look like a valid JDK`);
|
|
66
|
+
suggestedFixes.push(`Edit ${userProps} to remove or correct org.gradle.java.home`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (e) { /* ignore */ }
|
|
71
|
+
// 3) system gradle.properties
|
|
72
|
+
const systemProps = '/etc/gradle/gradle.properties';
|
|
73
|
+
filesChecked.push(systemProps);
|
|
74
|
+
try {
|
|
75
|
+
const props = readPropertiesFile(systemProps);
|
|
76
|
+
if (props['org.gradle.java.home']) {
|
|
77
|
+
const p = props['org.gradle.java.home'];
|
|
78
|
+
gradleJavaHome = gradleJavaHome || p;
|
|
79
|
+
if (!javaBinExists(p)) {
|
|
80
|
+
issues.push(`org.gradle.java.home in ${systemProps} points to '${p}' which does not look like a valid JDK`);
|
|
81
|
+
suggestedFixes.push(`Edit ${systemProps} to remove or correct org.gradle.java.home`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (e) { /* ignore */ }
|
|
86
|
+
// 4) GRADLE_HOME fallback
|
|
87
|
+
if (!gradleJavaHome && process.env.GRADLE_HOME) {
|
|
88
|
+
filesChecked.push(process.env.GRADLE_HOME);
|
|
89
|
+
if (javaBinExists(process.env.GRADLE_HOME)) {
|
|
90
|
+
gradleJavaHome = process.env.GRADLE_HOME;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const gradleValid = !!gradleJavaHome && javaBinExists(gradleJavaHome);
|
|
94
|
+
if (!gradleJavaHome) {
|
|
95
|
+
// no explicit gradle java home detected — not an issue
|
|
96
|
+
}
|
|
97
|
+
else if (!gradleValid) {
|
|
98
|
+
issues.push(`Detected org.gradle.java.home = '${gradleJavaHome}' but it is invalid`);
|
|
99
|
+
}
|
|
100
|
+
return { gradleJavaHome, gradleValid, filesChecked, issues, suggestedFixes };
|
|
101
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { checkAndroid } from './android.js';
|
|
2
|
+
import { checkIOS } from './ios.js';
|
|
3
|
+
import { checkGradle } from './gradle.js';
|
|
4
|
+
export async function getSystemStatus() {
|
|
5
|
+
try {
|
|
6
|
+
const android = await checkAndroid();
|
|
7
|
+
const ios = await checkIOS();
|
|
8
|
+
const gradle = await checkGradle();
|
|
9
|
+
const issues = [...android.issues, ...ios.issues, ...(gradle.issues || [])];
|
|
10
|
+
const success = issues.length === 0;
|
|
11
|
+
return {
|
|
12
|
+
success,
|
|
13
|
+
adbAvailable: android.adbAvailable,
|
|
14
|
+
adbVersion: android.adbVersion,
|
|
15
|
+
devices: android.devices,
|
|
16
|
+
deviceStates: android.deviceStates,
|
|
17
|
+
logsAvailable: android.logsAvailable,
|
|
18
|
+
envValid: android.envValid,
|
|
19
|
+
issues,
|
|
20
|
+
appInstalled: android.appInstalled,
|
|
21
|
+
iosAvailable: ios.iosAvailable,
|
|
22
|
+
iosDevices: ios.iosDevices,
|
|
23
|
+
gradleJavaHome: gradle.gradleJavaHome,
|
|
24
|
+
gradleValid: gradle.gradleValid,
|
|
25
|
+
gradleFilesChecked: gradle.filesChecked,
|
|
26
|
+
gradleSuggestedFixes: gradle.suggestedFixes
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
return { success: false, issues: ['Internal error: ' + (e instanceof Error ? e.message : String(e))] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { getXcrunCmd } from '../utils/ios/utils.js';
|
|
3
|
+
export async function checkIOS() {
|
|
4
|
+
const issues = [];
|
|
5
|
+
let iosAvailable = false;
|
|
6
|
+
let iosDevices = 0;
|
|
7
|
+
try {
|
|
8
|
+
const xcrun = getXcrunCmd();
|
|
9
|
+
try {
|
|
10
|
+
execSync(`${xcrun} --version`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500 });
|
|
11
|
+
iosAvailable = true;
|
|
12
|
+
try {
|
|
13
|
+
const simOut = execSync(`${xcrun} simctl list devices booted --json`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
14
|
+
const data = JSON.parse(simOut);
|
|
15
|
+
let count = 0;
|
|
16
|
+
for (const k in data.devices) {
|
|
17
|
+
const arr = data.devices[k];
|
|
18
|
+
if (Array.isArray(arr))
|
|
19
|
+
count += arr.filter((d) => (d.state || '').toLowerCase() === 'booted').length;
|
|
20
|
+
}
|
|
21
|
+
iosDevices = count;
|
|
22
|
+
if (iosDevices === 0)
|
|
23
|
+
issues.push('No iOS simulators/devices booted');
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
console.debug('[get_system_status] simctl list failed: ' + String(e));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
iosAvailable = false;
|
|
31
|
+
console.debug('[get_system_status] xcrun --version failed: ' + String(e));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
console.debug('[get_system_status] xcrun check failed: ' + String(e));
|
|
36
|
+
}
|
|
37
|
+
return { iosAvailable, iosDevices, issues };
|
|
38
|
+
}
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { detectJavaHome } from '../java.js';
|
|
4
4
|
import { execCmd } from '../exec.js';
|
|
5
5
|
import { spawnSync } from 'child_process';
|
|
6
|
+
import { checkGradle } from '../../system/gradle.js';
|
|
6
7
|
function findInPath(cmd) {
|
|
7
8
|
try {
|
|
8
9
|
// prefer command -v for POSIX
|
|
@@ -76,6 +77,14 @@ export async function prepareGradle(projectPath) {
|
|
|
76
77
|
gradleArgs.push('-Dorg.gradle.caching=false');
|
|
77
78
|
}
|
|
78
79
|
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
80
|
+
// Check for problematic org.gradle.java.home entries (env or properties) and avoid passing invalid values to Gradle
|
|
81
|
+
let gradleCheck;
|
|
82
|
+
try {
|
|
83
|
+
gradleCheck = await checkGradle();
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] };
|
|
87
|
+
}
|
|
79
88
|
const env = Object.assign({}, process.env);
|
|
80
89
|
// Ensure child processes can find Android platform-tools (adb, etc.) by
|
|
81
90
|
// prepending the platform-tools directory to PATH for spawned processes.
|
|
@@ -90,6 +99,7 @@ export async function prepareGradle(projectPath) {
|
|
|
90
99
|
console.debug(`[prepareGradle] error resolving adbPath: ${String(e)}`);
|
|
91
100
|
}
|
|
92
101
|
const pathParts = [];
|
|
102
|
+
// Prefer a detected (validated) Java home from the system/IDE
|
|
93
103
|
if (detectedJavaHome) {
|
|
94
104
|
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
95
105
|
env.JAVA_HOME = detectedJavaHome;
|
|
@@ -100,6 +110,26 @@ export async function prepareGradle(projectPath) {
|
|
|
100
110
|
gradleArgs.push('--no-daemon');
|
|
101
111
|
env.GRADLE_JAVA_HOME = detectedJavaHome;
|
|
102
112
|
}
|
|
113
|
+
else if (gradleCheck && gradleCheck.gradleJavaHome) {
|
|
114
|
+
// There's an org.gradle.java.home configured somewhere (env or gradle.properties)
|
|
115
|
+
if (gradleCheck.gradleValid) {
|
|
116
|
+
const p = gradleCheck.gradleJavaHome;
|
|
117
|
+
const javaBin = path.join(p, 'bin');
|
|
118
|
+
if (!env.PATH || !env.PATH.includes(javaBin))
|
|
119
|
+
pathParts.push(javaBin);
|
|
120
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${p}`);
|
|
121
|
+
gradleArgs.push('--no-daemon');
|
|
122
|
+
env.GRADLE_JAVA_HOME = p;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// Invalid gradle java home detected: avoid passing it to Gradle and remove from spawn env
|
|
126
|
+
console.debug(`[prepareGradle] Invalid org.gradle.java.home detected (${gradleCheck.gradleJavaHome}); removing from spawn env to avoid Gradle error.`);
|
|
127
|
+
try {
|
|
128
|
+
delete env.GRADLE_JAVA_HOME;
|
|
129
|
+
}
|
|
130
|
+
catch (e) { }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
103
133
|
if (platformToolsDir) {
|
|
104
134
|
// Prepend platform-tools so gradle and child tools find adb without modifying global env
|
|
105
135
|
if (!env.PATH || !env.PATH.includes(platformToolsDir)) {
|
|
@@ -138,10 +168,12 @@ export async function prepareGradle(projectPath) {
|
|
|
138
168
|
catch (e) {
|
|
139
169
|
console.debug('[prepareGradle] chmod failed for gradlew:', String(e));
|
|
140
170
|
}
|
|
171
|
+
// Execute the wrapper directly without a shell to avoid shell tokenization of args (spaces in paths)
|
|
141
172
|
spawnOpts.shell = false;
|
|
142
173
|
}
|
|
143
174
|
else {
|
|
144
|
-
|
|
175
|
+
// Prefer executing gradle directly without invoking a shell to preserve argument boundaries
|
|
176
|
+
spawnOpts.shell = false;
|
|
145
177
|
}
|
|
146
178
|
return { execCmd, gradleArgs, spawnOpts };
|
|
147
179
|
}
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.20.1]
|
|
6
|
+
- Fixes gradle home issue for android
|
|
7
|
+
|
|
8
|
+
## [0.20.0]
|
|
9
|
+
- Added `get_system_status` tool and refactored system health checks into `src/system`.
|
|
10
|
+
- Provides a fast environment healthcheck (ADB availability/version, connected devices, log access, Android env vars, and basic iOS xcrun/simulator checks).
|
|
11
|
+
- Designed to be fast, non-throwing, and to gate agent actions early. Unit tests added.
|
|
12
|
+
|
|
13
|
+
|
|
5
14
|
## [0.19.2]
|
|
6
15
|
- Added healthcheck improvments
|
|
7
16
|
- Added skills
|
package/docs/tools/TOOLS.md
CHANGED
|
@@ -7,5 +7,6 @@ See:
|
|
|
7
7
|
- [mange](manage.md) — build, install and device management tools
|
|
8
8
|
- [observe](observe.md) — logs, screenshots and UI inspection tools
|
|
9
9
|
- [interact](interact.md) — UI interaction tools (tap, swipe, type, wait)
|
|
10
|
+
- [system](system.md) — environment and health checks (get_system_status)
|
|
10
11
|
|
|
11
12
|
For per-tool deep dives, open the linked files above.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# System (environment & health checks)
|
|
2
|
+
|
|
3
|
+
Tools that provide a lightweight view of the local mobile debugging environment and surface issues early so agents can decide whether to proceed.
|
|
4
|
+
|
|
5
|
+
## get_system_status
|
|
6
|
+
A fast, non-throwing healthcheck that inspects key dependencies and connections required for mobile debugging.
|
|
7
|
+
|
|
8
|
+
Input:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
{}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Response (example):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"success": true,
|
|
19
|
+
"adbAvailable": true,
|
|
20
|
+
"adbVersion": "8.1.0",
|
|
21
|
+
"devices": 1,
|
|
22
|
+
"deviceStates": "1 device",
|
|
23
|
+
"logsAvailable": true,
|
|
24
|
+
"envValid": true,
|
|
25
|
+
"issues": [],
|
|
26
|
+
"appInstalled": true,
|
|
27
|
+
"iosAvailable": true,
|
|
28
|
+
"iosDevices": 1
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Checks performed (fast, best-effort):
|
|
33
|
+
- ADB availability and version (adb --version)
|
|
34
|
+
- Connected Android devices (adb devices -l), counts and state summary (device/unauthorized/offline)
|
|
35
|
+
- Log access probe (adb logcat -d -t 1)
|
|
36
|
+
- Android environment variables (ANDROID_SDK_ROOT / ANDROID_HOME / PATH contains adb)
|
|
37
|
+
- Optional: app installation check if MCP_TARGET_PACKAGE/MCP_TARGET_APP_ID is set (pm path)
|
|
38
|
+
- Basic iOS checks (xcrun --version and simctl list devices booted)
|
|
39
|
+
|
|
40
|
+
Behavior notes:
|
|
41
|
+
- Always returns structured JSON and never throws; any failures are surfaced in the `issues` array.
|
|
42
|
+
- Designed to be fast (<~1s probes where possible); startup callers may prefer a `fastMode` variant that only checks existence.
|
|
43
|
+
- Useful to call at the start of an agent session to gate subsequent actions.
|
|
44
|
+
|
|
45
|
+
Usage guidance:
|
|
46
|
+
- Call before build/install flows to avoid wasted build attempts on misconfigured systems.
|
|
47
|
+
- If `success: false`, attempt recovery steps or report issues to the user.
|
|
48
|
+
|
package/package.json
CHANGED
package/src/manage/android.ts
CHANGED
|
@@ -64,8 +64,12 @@ export class AndroidManage {
|
|
|
64
64
|
const spawnOpts: any = { cwd: apkPath, env }
|
|
65
65
|
if (useWrapper) {
|
|
66
66
|
await fs.chmod(wrapperPath, 0o755).catch(() => {})
|
|
67
|
+
// Run wrapper directly to avoid shell splitting of args
|
|
67
68
|
spawnOpts.shell = false
|
|
68
|
-
} else
|
|
69
|
+
} else {
|
|
70
|
+
// Execute gradle directly without a shell so paths with spaces are preserved
|
|
71
|
+
spawnOpts.shell = false
|
|
72
|
+
}
|
|
69
73
|
|
|
70
74
|
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
71
75
|
let stderr = ''
|
package/src/server.ts
CHANGED
|
@@ -20,10 +20,6 @@ import { ToolsInteract } from './interact/index.js'
|
|
|
20
20
|
import { ToolsObserve } from './observe/index.js'
|
|
21
21
|
import { AndroidManage } from './manage/index.js'
|
|
22
22
|
import { iOSManage } from './manage/index.js'
|
|
23
|
-
import { ensureAdbAvailable } from './utils/android/utils.js'
|
|
24
|
-
import { getIdbCmd, isIDBInstalled } from './utils/ios/utils.js'
|
|
25
|
-
import { getXcrunCmd } from './utils/ios/utils.js'
|
|
26
|
-
import { execSync } from 'child_process'
|
|
27
23
|
|
|
28
24
|
|
|
29
25
|
const server = new Server(
|
|
@@ -38,44 +34,12 @@ const server = new Server(
|
|
|
38
34
|
}
|
|
39
35
|
);
|
|
40
36
|
|
|
41
|
-
|
|
42
|
-
(async () => {
|
|
43
|
-
try {
|
|
44
|
-
const adbCheck = ensureAdbAvailable()
|
|
45
|
-
if (adbCheck.ok) console.debug('[startup] adb available:', adbCheck.adbCmd, adbCheck.version)
|
|
46
|
-
else console.warn('[startup] adb not available or failed to run:', adbCheck.adbCmd, adbCheck.error)
|
|
47
|
-
} catch (e: unknown) {
|
|
48
|
-
if (e instanceof Error) {
|
|
49
|
-
console.warn('[startup] error during adb healthcheck:', e.message)
|
|
50
|
-
} else {
|
|
51
|
-
console.warn('[startup] error during adb healthcheck:', String(e))
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Check idb availability (non-fatal)
|
|
56
|
-
try {
|
|
57
|
-
const idbInstalled = await isIDBInstalled()
|
|
58
|
-
const idbCmd = getIdbCmd()
|
|
59
|
-
if (idbInstalled) console.debug('[startup] idb available:', idbCmd)
|
|
60
|
-
else console.debug('[startup] idb not available or failed to run:', idbCmd)
|
|
61
|
-
} catch (e: unknown) {
|
|
62
|
-
console.warn('[startup] error during idb healthcheck:', e instanceof Error ? e.message : String(e))
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Check xcrun availability (non-fatal)
|
|
66
|
-
try {
|
|
67
|
-
const xcrun = getXcrunCmd()
|
|
68
|
-
try {
|
|
69
|
-
const out = execSync(`${xcrun} --version`, { stdio: ['ignore','pipe','ignore'] }).toString().trim()
|
|
70
|
-
console.debug('[startup] xcrun available:', xcrun, out.split('\n')[0])
|
|
71
|
-
} catch (err: unknown) {
|
|
72
|
-
console.warn('[startup] xcrun not available or failed to run:', xcrun, err instanceof Error ? err.message : String(err))
|
|
73
|
-
}
|
|
74
|
-
} catch (e: unknown) {
|
|
75
|
-
console.warn('[startup] error during xcrun healthcheck:', e instanceof Error ? e.message : String(e))
|
|
76
|
-
}
|
|
37
|
+
import { getSystemStatus } from './system/index.js'
|
|
77
38
|
|
|
78
|
-
|
|
39
|
+
// Run a quick startup healthcheck (non-fatal) by calling getSystemStatus directly and log a short summary
|
|
40
|
+
getSystemStatus().then(res => {
|
|
41
|
+
console.debug('[startup] system status summary:', { adb: res.adbAvailable, ios: res.iosAvailable, devices: res.devices, iosDevices: res.iosDevices })
|
|
42
|
+
}).catch(e => console.warn('[startup] healthcheck failed:', e instanceof Error ? e.message : String(e)))
|
|
79
43
|
|
|
80
44
|
function wrapResponse<T>(data: T) {
|
|
81
45
|
return {
|
|
@@ -241,6 +205,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
241
205
|
}
|
|
242
206
|
}
|
|
243
207
|
},
|
|
208
|
+
{
|
|
209
|
+
name: "get_system_status",
|
|
210
|
+
description: "Quick healthcheck of local mobile debugging environment (adb, devices, logs, env, iOS).",
|
|
211
|
+
inputSchema: { type: "object", properties: {} }
|
|
212
|
+
},
|
|
244
213
|
{
|
|
245
214
|
name: "capture_screenshot",
|
|
246
215
|
description: "Capture a screenshot from an Android device or iOS simulator. Returns device metadata and the screenshot image.",
|
|
@@ -658,6 +627,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
658
627
|
return wrapResponse(res)
|
|
659
628
|
}
|
|
660
629
|
|
|
630
|
+
if (name === "get_system_status") {
|
|
631
|
+
const result = await getSystemStatus()
|
|
632
|
+
return wrapResponse(result)
|
|
633
|
+
}
|
|
634
|
+
|
|
661
635
|
|
|
662
636
|
if (name === "capture_screenshot") {
|
|
663
637
|
const { platform, deviceId } = args as any
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
import { ensureAdbAvailable } from '../utils/android/utils.js'
|
|
3
|
+
|
|
4
|
+
export async function checkAndroid() {
|
|
5
|
+
const issues: string[] = []
|
|
6
|
+
let adbAvailable = false
|
|
7
|
+
let adbVersion = ''
|
|
8
|
+
let devices = 0
|
|
9
|
+
let deviceStates = ''
|
|
10
|
+
let logsAvailable = false
|
|
11
|
+
let envValid = false
|
|
12
|
+
let appInstalled: boolean | undefined = undefined
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const adbCheck = ensureAdbAvailable()
|
|
16
|
+
const adbCmd = adbCheck.adbCmd || 'adb'
|
|
17
|
+
adbAvailable = !!adbCheck.ok
|
|
18
|
+
adbVersion = (adbCheck.version || '').toString().split('\n')[0]
|
|
19
|
+
if (!adbAvailable) issues.push('ADB not available')
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const out = execSync(`${adbCmd} devices -l`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore','pipe','ignore'] }).toString()
|
|
23
|
+
const lines = out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
24
|
+
const deviceLines = lines.filter(l => !l.startsWith('List of devices'))
|
|
25
|
+
const stateCounts: Record<string, number> = {}
|
|
26
|
+
for (const l of deviceLines) {
|
|
27
|
+
const parts = l.split(/\s+/)
|
|
28
|
+
const state = parts[1] || ''
|
|
29
|
+
stateCounts[state] = (stateCounts[state] || 0) + 1
|
|
30
|
+
}
|
|
31
|
+
devices = deviceLines.length
|
|
32
|
+
const parts = Object.entries(stateCounts).map(([k,v]) => `${v} ${k}`)
|
|
33
|
+
deviceStates = parts.join(', ')
|
|
34
|
+
if (devices === 0) issues.push('No Android devices connected')
|
|
35
|
+
if (stateCounts['unauthorized']) issues.push(`${stateCounts['unauthorized']} device(s) unauthorized`)
|
|
36
|
+
if (stateCounts['offline']) issues.push(`${stateCounts['offline']} device(s) offline`)
|
|
37
|
+
} catch (e: unknown) { console.debug('[get_system_status] adb devices failed: ' + String(e)); issues.push('Failed to list Android devices') }
|
|
38
|
+
|
|
39
|
+
if (adbAvailable && devices > 0) {
|
|
40
|
+
try {
|
|
41
|
+
const lo = execSync(`${adbCmd} logcat -d -t 1`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore','pipe','ignore'] }).toString()
|
|
42
|
+
logsAvailable = !!lo
|
|
43
|
+
if (!logsAvailable) issues.push('Log access failed')
|
|
44
|
+
} catch (e: unknown) { logsAvailable = false; console.debug('[get_system_status] logcat check failed: ' + String(e)) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME
|
|
48
|
+
envValid = !!sdkRoot || (adbAvailable === true && !!(adbCheck.adbCmd && adbCheck.adbCmd !== 'adb'))
|
|
49
|
+
if (!envValid) issues.push('ANDROID_SDK_ROOT/ANDROID_HOME missing and adb not found in PATH')
|
|
50
|
+
|
|
51
|
+
const pkg = process.env.MCP_TARGET_PACKAGE || process.env.MCP_TARGET_APP_ID
|
|
52
|
+
if (pkg && adbAvailable && devices > 0) {
|
|
53
|
+
try {
|
|
54
|
+
const pm = execSync(`${adbCmd} shell pm path ${pkg}`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore','pipe','ignore'] }).toString()
|
|
55
|
+
appInstalled = (pm || '').includes('package:')
|
|
56
|
+
if (!appInstalled) issues.push(`App ${pkg} not installed on devices`)
|
|
57
|
+
} catch (e: unknown) { appInstalled = false; console.debug('[get_system_status] pm check failed: ' + String(e)) }
|
|
58
|
+
}
|
|
59
|
+
} catch (e: unknown) { console.debug('[get_system_status] adb availability check failed: ' + String(e)); issues.push('ADB check failed') }
|
|
60
|
+
|
|
61
|
+
return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, issues }
|
|
62
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
|
|
5
|
+
function readPropertiesFile(p: string): Record<string,string> {
|
|
6
|
+
try {
|
|
7
|
+
const txt = readFileSync(p, { encoding: 'utf8' })
|
|
8
|
+
const lines = String(txt).split(/\r?\n/)
|
|
9
|
+
const out: Record<string,string> = {}
|
|
10
|
+
for (const l of lines) {
|
|
11
|
+
const trimmed = l.trim()
|
|
12
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
13
|
+
const idx = trimmed.indexOf('=')
|
|
14
|
+
if (idx === -1) continue
|
|
15
|
+
const k = trimmed.substring(0, idx).trim()
|
|
16
|
+
const v = trimmed.substring(idx+1).trim()
|
|
17
|
+
out[k] = v
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
20
|
+
} catch (e: unknown) {
|
|
21
|
+
return {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function javaBinExists(p?: string): boolean {
|
|
26
|
+
if (!p) return false
|
|
27
|
+
try {
|
|
28
|
+
const javaPath = path.join(p, 'bin', 'java')
|
|
29
|
+
if (existsSync(javaPath)) return true
|
|
30
|
+
const alt = path.join(p, 'Contents', 'Home', 'bin', 'java')
|
|
31
|
+
if (existsSync(alt)) return true
|
|
32
|
+
return false
|
|
33
|
+
} catch (e: unknown) { return false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function checkGradle(): Promise<{ gradleJavaHome?: string; gradleValid: boolean; filesChecked: string[]; issues: string[]; suggestedFixes?: string[] }> {
|
|
37
|
+
const issues: string[] = []
|
|
38
|
+
const filesChecked: string[] = []
|
|
39
|
+
const suggestedFixes: string[] = []
|
|
40
|
+
let gradleJavaHome: string | undefined
|
|
41
|
+
|
|
42
|
+
// 1) explicit env
|
|
43
|
+
if (process.env.GRADLE_JAVA_HOME) {
|
|
44
|
+
gradleJavaHome = process.env.GRADLE_JAVA_HOME
|
|
45
|
+
if (!javaBinExists(gradleJavaHome)) {
|
|
46
|
+
issues.push(`GRADLE_JAVA_HOME is set to '${gradleJavaHome}' but no java binary was found there`)
|
|
47
|
+
suggestedFixes.push('Unset GRADLE_JAVA_HOME or point it to a valid JDK (e.g., /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home)')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2) user gradle.properties
|
|
52
|
+
const gradleUserHome = process.env.GRADLE_USER_HOME || path.join(os.homedir(), '.gradle')
|
|
53
|
+
const userProps = path.join(gradleUserHome, 'gradle.properties')
|
|
54
|
+
filesChecked.push(userProps)
|
|
55
|
+
try {
|
|
56
|
+
const props = readPropertiesFile(userProps)
|
|
57
|
+
if (props['org.gradle.java.home']) {
|
|
58
|
+
const p = props['org.gradle.java.home']
|
|
59
|
+
gradleJavaHome = gradleJavaHome || p
|
|
60
|
+
if (!javaBinExists(p)) {
|
|
61
|
+
issues.push(`org.gradle.java.home in ${userProps} points to '${p}' which does not look like a valid JDK`)
|
|
62
|
+
suggestedFixes.push(`Edit ${userProps} to remove or correct org.gradle.java.home`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (e: unknown) { /* ignore */ }
|
|
66
|
+
|
|
67
|
+
// 3) system gradle.properties
|
|
68
|
+
const systemProps = '/etc/gradle/gradle.properties'
|
|
69
|
+
filesChecked.push(systemProps)
|
|
70
|
+
try {
|
|
71
|
+
const props = readPropertiesFile(systemProps)
|
|
72
|
+
if (props['org.gradle.java.home']) {
|
|
73
|
+
const p = props['org.gradle.java.home']
|
|
74
|
+
gradleJavaHome = gradleJavaHome || p
|
|
75
|
+
if (!javaBinExists(p)) {
|
|
76
|
+
issues.push(`org.gradle.java.home in ${systemProps} points to '${p}' which does not look like a valid JDK`)
|
|
77
|
+
suggestedFixes.push(`Edit ${systemProps} to remove or correct org.gradle.java.home`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (e: unknown) { /* ignore */ }
|
|
81
|
+
|
|
82
|
+
// 4) GRADLE_HOME fallback
|
|
83
|
+
if (!gradleJavaHome && process.env.GRADLE_HOME) {
|
|
84
|
+
filesChecked.push(process.env.GRADLE_HOME)
|
|
85
|
+
if (javaBinExists(process.env.GRADLE_HOME)) {
|
|
86
|
+
gradleJavaHome = process.env.GRADLE_HOME
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const gradleValid = !!gradleJavaHome && javaBinExists(gradleJavaHome)
|
|
91
|
+
if (!gradleJavaHome) {
|
|
92
|
+
// no explicit gradle java home detected — not an issue
|
|
93
|
+
} else if (!gradleValid) {
|
|
94
|
+
issues.push(`Detected org.gradle.java.home = '${gradleJavaHome}' but it is invalid`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { gradleJavaHome, gradleValid, filesChecked, issues, suggestedFixes }
|
|
98
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { checkAndroid } from './android.js'
|
|
2
|
+
import { checkIOS } from './ios.js'
|
|
3
|
+
import { checkGradle } from './gradle.js'
|
|
4
|
+
|
|
5
|
+
export async function getSystemStatus() {
|
|
6
|
+
try {
|
|
7
|
+
const android = await checkAndroid()
|
|
8
|
+
const ios = await checkIOS()
|
|
9
|
+
const gradle = await checkGradle()
|
|
10
|
+
const issues = [...android.issues, ...ios.issues, ...(gradle.issues || [])]
|
|
11
|
+
|
|
12
|
+
const success = issues.length === 0
|
|
13
|
+
return {
|
|
14
|
+
success,
|
|
15
|
+
adbAvailable: android.adbAvailable,
|
|
16
|
+
adbVersion: android.adbVersion,
|
|
17
|
+
devices: android.devices,
|
|
18
|
+
deviceStates: android.deviceStates,
|
|
19
|
+
logsAvailable: android.logsAvailable,
|
|
20
|
+
envValid: android.envValid,
|
|
21
|
+
issues,
|
|
22
|
+
appInstalled: android.appInstalled,
|
|
23
|
+
iosAvailable: ios.iosAvailable,
|
|
24
|
+
iosDevices: ios.iosDevices,
|
|
25
|
+
gradleJavaHome: gradle.gradleJavaHome,
|
|
26
|
+
gradleValid: gradle.gradleValid,
|
|
27
|
+
gradleFilesChecked: gradle.filesChecked,
|
|
28
|
+
gradleSuggestedFixes: gradle.suggestedFixes
|
|
29
|
+
}
|
|
30
|
+
} catch (e: unknown) {
|
|
31
|
+
return { success: false, issues: ['Internal error: ' + (e instanceof Error ? e.message : String(e))] }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
import { getXcrunCmd } from '../utils/ios/utils.js'
|
|
3
|
+
|
|
4
|
+
export async function checkIOS() {
|
|
5
|
+
const issues: string[] = []
|
|
6
|
+
let iosAvailable = false
|
|
7
|
+
let iosDevices = 0
|
|
8
|
+
try {
|
|
9
|
+
const xcrun = getXcrunCmd()
|
|
10
|
+
try {
|
|
11
|
+
execSync(`${xcrun} --version`, { stdio: ['ignore','pipe','ignore'], timeout: 1500 })
|
|
12
|
+
iosAvailable = true
|
|
13
|
+
try {
|
|
14
|
+
const simOut = execSync(`${xcrun} simctl list devices booted --json`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore','pipe','ignore'] })
|
|
15
|
+
const data = JSON.parse(simOut)
|
|
16
|
+
type SimDevice = { state?: string }
|
|
17
|
+
let count = 0
|
|
18
|
+
for (const k in data.devices) {
|
|
19
|
+
const arr = data.devices[k]
|
|
20
|
+
if (Array.isArray(arr)) count += arr.filter((d: SimDevice) => (d.state || '').toLowerCase() === 'booted').length
|
|
21
|
+
}
|
|
22
|
+
iosDevices = count
|
|
23
|
+
if (iosDevices === 0) issues.push('No iOS simulators/devices booted')
|
|
24
|
+
} catch (e: unknown) { console.debug('[get_system_status] simctl list failed: ' + String(e)) }
|
|
25
|
+
} catch (e: unknown) { iosAvailable = false; console.debug('[get_system_status] xcrun --version failed: ' + String(e)) }
|
|
26
|
+
} catch (e: unknown) { console.debug('[get_system_status] xcrun check failed: ' + String(e)) }
|
|
27
|
+
return { iosAvailable, iosDevices, issues }
|
|
28
|
+
}
|
|
@@ -4,6 +4,7 @@ import path from 'path'
|
|
|
4
4
|
import { detectJavaHome } from '../java.js'
|
|
5
5
|
import { execCmd } from '../exec.js'
|
|
6
6
|
import { spawnSync } from 'child_process'
|
|
7
|
+
import { checkGradle } from '../../system/gradle.js'
|
|
7
8
|
|
|
8
9
|
function findInPath(cmd: string): string | null {
|
|
9
10
|
try {
|
|
@@ -73,6 +74,14 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
const detectedJavaHome = await detectJavaHome().catch(() => undefined)
|
|
77
|
+
// Check for problematic org.gradle.java.home entries (env or properties) and avoid passing invalid values to Gradle
|
|
78
|
+
let gradleCheck
|
|
79
|
+
try {
|
|
80
|
+
gradleCheck = await checkGradle()
|
|
81
|
+
} catch (e: unknown) {
|
|
82
|
+
gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] }
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
const env = Object.assign({}, process.env)
|
|
77
86
|
|
|
78
87
|
// Ensure child processes can find Android platform-tools (adb, etc.) by
|
|
@@ -86,6 +95,7 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
|
|
|
86
95
|
} catch (e: unknown) { console.debug(`[prepareGradle] error resolving adbPath: ${String(e)}`) }
|
|
87
96
|
|
|
88
97
|
const pathParts: string[] = []
|
|
98
|
+
// Prefer a detected (validated) Java home from the system/IDE
|
|
89
99
|
if (detectedJavaHome) {
|
|
90
100
|
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
91
101
|
env.JAVA_HOME = detectedJavaHome
|
|
@@ -95,6 +105,20 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
|
|
|
95
105
|
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
|
|
96
106
|
gradleArgs.push('--no-daemon')
|
|
97
107
|
env.GRADLE_JAVA_HOME = detectedJavaHome
|
|
108
|
+
} else if (gradleCheck && gradleCheck.gradleJavaHome) {
|
|
109
|
+
// There's an org.gradle.java.home configured somewhere (env or gradle.properties)
|
|
110
|
+
if (gradleCheck.gradleValid) {
|
|
111
|
+
const p = gradleCheck.gradleJavaHome as string
|
|
112
|
+
const javaBin = path.join(p, 'bin')
|
|
113
|
+
if (!env.PATH || !env.PATH.includes(javaBin)) pathParts.push(javaBin)
|
|
114
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${p}`)
|
|
115
|
+
gradleArgs.push('--no-daemon')
|
|
116
|
+
env.GRADLE_JAVA_HOME = p
|
|
117
|
+
} else {
|
|
118
|
+
// Invalid gradle java home detected: avoid passing it to Gradle and remove from spawn env
|
|
119
|
+
console.debug(`[prepareGradle] Invalid org.gradle.java.home detected (${gradleCheck.gradleJavaHome}); removing from spawn env to avoid Gradle error.`)
|
|
120
|
+
try { delete env.GRADLE_JAVA_HOME } catch (e: unknown) { }
|
|
121
|
+
}
|
|
98
122
|
}
|
|
99
123
|
|
|
100
124
|
if (platformToolsDir) {
|
|
@@ -126,9 +150,11 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
|
|
|
126
150
|
const spawnOpts: any = { cwd: projectPath, env }
|
|
127
151
|
if (useWrapper) {
|
|
128
152
|
try { await fsPromises.chmod(gradlewPath, 0o755) } catch (e: unknown) { console.debug('[prepareGradle] chmod failed for gradlew:', String(e)) }
|
|
153
|
+
// Execute the wrapper directly without a shell to avoid shell tokenization of args (spaces in paths)
|
|
129
154
|
spawnOpts.shell = false
|
|
130
155
|
} else {
|
|
131
|
-
|
|
156
|
+
// Prefer executing gradle directly without invoking a shell to preserve argument boundaries
|
|
157
|
+
spawnOpts.shell = false
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
return { execCmd, gradleArgs, spawnOpts }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import * as androidUtils from '../../src/utils/android/utils.js'
|
|
3
|
+
import * as systemStatus from '../../src/system/index.js'
|
|
4
|
+
|
|
5
|
+
const origEnsure = (androidUtils as any).ensureAdbAvailable
|
|
6
|
+
|
|
7
|
+
function mockEnsure(returnVal: any) {
|
|
8
|
+
(androidUtils as any).ensureAdbAvailable = () => returnVal
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function restoreEnsure() {
|
|
12
|
+
(androidUtils as any).ensureAdbAvailable = origEnsure
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('adb version parsing', () => {
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
restoreEnsure()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('uses only the first line of multi-line adb --version output', async () => {
|
|
21
|
+
mockEnsure({ adbCmd: 'adb', ok: true, version: 'Android Debug Bridge version 1.0.41\nRevision 8f3b7' })
|
|
22
|
+
const res = await systemStatus.getSystemStatus()
|
|
23
|
+
assert.strictEqual(res.adbVersion, 'Android Debug Bridge version 1.0.41')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { ensureAdbAvailable } from '../../src/utils/android/utils.js'
|
|
3
|
+
import { getXcrunCmd } from '../../src/utils/ios/utils.js'
|
|
4
|
+
|
|
5
|
+
// We import the server handler module to access the internal get_system_status implementation.
|
|
6
|
+
import * as server from '../../src/server.js'
|
|
7
|
+
|
|
8
|
+
// Small helper to call the tool handler similarly to how the MCP transport would.
|
|
9
|
+
async function callGetSystemStatus() {
|
|
10
|
+
const req = { params: { name: 'get_system_status', arguments: {} } }
|
|
11
|
+
// @ts-ignore - use the handler exported from server
|
|
12
|
+
const handler = (server as any).defaultRequestHandler || (server as any).callToolHandler || (server as any).__callTool
|
|
13
|
+
if (!handler) {
|
|
14
|
+
// fallback: require the module and call the exported server instance's request handler
|
|
15
|
+
// The server code registers the handler directly; we will emulate by requiring compiled code in dist if available.
|
|
16
|
+
try {
|
|
17
|
+
const dist = await import('../../dist/server.js')
|
|
18
|
+
// Try to execute by sending a call via the server instance if exported
|
|
19
|
+
if (dist && dist.server && typeof dist.server._handleCall === 'function') {
|
|
20
|
+
return dist.server._handleCall(req)
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// best effort only
|
|
24
|
+
}
|
|
25
|
+
throw new Error('Cannot locate server call handler for tests')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return handler(req)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('get_system_status tool (unit)', () => {
|
|
32
|
+
it('returns structured result without throwing', async () => {
|
|
33
|
+
const res = await callGetSystemStatus()
|
|
34
|
+
// Handler returns { content: [{ type: 'text', text: JSON.stringify(...) }] }
|
|
35
|
+
assert(res && res.content && Array.isArray(res.content))
|
|
36
|
+
const textBlock = res.content.find((c: any) => c.type === 'text')
|
|
37
|
+
assert(textBlock && textBlock.text)
|
|
38
|
+
const payload = JSON.parse(textBlock.text)
|
|
39
|
+
assert(typeof payload.success === 'boolean')
|
|
40
|
+
assert(Array.isArray(payload.issues))
|
|
41
|
+
}).timeout(5000)
|
|
42
|
+
|
|
43
|
+
it('detects adb availability helper works', () => {
|
|
44
|
+
const adb = ensureAdbAvailable()
|
|
45
|
+
assert(adb && typeof adb.ok === 'boolean')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('detects xcrun command helper exists', () => {
|
|
49
|
+
const cmd = getXcrunCmd()
|
|
50
|
+
assert(typeof cmd === 'string')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import child_process from 'child_process'
|
|
3
|
+
|
|
4
|
+
import * as androidUtils from '../../src/utils/android/utils.js'
|
|
5
|
+
import * as iosUtils from '../../src/utils/ios/utils.js'
|
|
6
|
+
import * as systemStatus from '../../src/system/index.js'
|
|
7
|
+
|
|
8
|
+
const origExecSync = child_process.execSync
|
|
9
|
+
const origEnsure = (androidUtils as any).ensureAdbAvailable
|
|
10
|
+
const origGetXcrun = (iosUtils as any).getXcrunCmd
|
|
11
|
+
|
|
12
|
+
function mockExec(behaviour: (cmd: string) => string) {
|
|
13
|
+
(child_process as any).execSync = (cmd: string) => {
|
|
14
|
+
const s = typeof cmd === 'string' ? cmd : (Array.isArray(cmd) ? cmd.join(' ') : String(cmd))
|
|
15
|
+
const out = behaviour(s)
|
|
16
|
+
if (out instanceof Error) throw out
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function restoreExec() {
|
|
22
|
+
(child_process as any).execSync = origExecSync
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mockEnsure(returnVal: any) {
|
|
26
|
+
(androidUtils as any).ensureAdbAvailable = () => returnVal
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function restoreEnsure() {
|
|
30
|
+
(androidUtils as any).ensureAdbAvailable = origEnsure
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mockGetXcrun(val: string) {
|
|
34
|
+
(iosUtils as any).getXcrunCmd = () => val
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function restoreGetXcrun() {
|
|
38
|
+
(iosUtils as any).getXcrunCmd = origGetXcrun
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('system_status checks', () => {
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
restoreExec(); restoreEnsure(); restoreGetXcrun()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('reports healthy system when adb and xcrun present', async () => {
|
|
47
|
+
mockEnsure({ adbCmd: 'adb', ok: true, version: '8.1.0' })
|
|
48
|
+
mockGetXcrun('xcrun')
|
|
49
|
+
|
|
50
|
+
mockExec((cmd) => {
|
|
51
|
+
if (cmd.startsWith('adb devices')) return 'List of devices attached\nemulator-5554\tdevice'
|
|
52
|
+
if (cmd.includes('adb logcat')) return 'I/Tag: ok'
|
|
53
|
+
if (cmd.includes('adb shell pm path')) return 'package:/data/app/com.example-1/base.apk'
|
|
54
|
+
if (cmd.startsWith('xcrun --version')) return 'xcrun version 123'
|
|
55
|
+
if (cmd.includes('simctl list devices booted --json')) return JSON.stringify({ devices: {} })
|
|
56
|
+
return ''
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const res = await systemStatus.getSystemStatus()
|
|
60
|
+
assert.strictEqual(res.success, true)
|
|
61
|
+
assert.strictEqual(res.adbAvailable, true)
|
|
62
|
+
assert.strictEqual(typeof res.adbVersion, 'string')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('reports adb missing', async () => {
|
|
66
|
+
mockEnsure({ adbCmd: 'adb', ok: false, error: 'not found' })
|
|
67
|
+
mockGetXcrun('xcrun')
|
|
68
|
+
mockExec((cmd) => {
|
|
69
|
+
if (cmd.startsWith('xcrun --version')) return 'xcrun version'
|
|
70
|
+
return ''
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const res = await systemStatus.getSystemStatus()
|
|
74
|
+
assert.strictEqual(res.success, false)
|
|
75
|
+
assert(res.issues.some((i: string) => i.includes('ADB')))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('detects unauthorized/offline devices', async () => {
|
|
79
|
+
mockEnsure({ adbCmd: 'adb', ok: true, version: '8.1.0' })
|
|
80
|
+
mockGetXcrun('xcrun')
|
|
81
|
+
mockExec((cmd) => {
|
|
82
|
+
if (cmd.startsWith('adb devices')) return 'List of devices attached\nserial1\tunauthorized\nserial2\toffline\n'
|
|
83
|
+
if (cmd.startsWith('xcrun --version')) return 'xcrun version'
|
|
84
|
+
return ''
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const res = await systemStatus.getSystemStatus()
|
|
88
|
+
assert.strictEqual(res.success, false)
|
|
89
|
+
assert(res.issues.some((i: string) => i.includes('unauthorized')))
|
|
90
|
+
assert(res.issues.some((i: string) => i.includes('offline')))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('handles missing xcrun gracefully', async () => {
|
|
94
|
+
mockEnsure({ adbCmd: 'adb', ok: true, version: '8.1.0' })
|
|
95
|
+
mockGetXcrun('xcrun')
|
|
96
|
+
mockExec((cmd) => {
|
|
97
|
+
if (cmd.startsWith('adb devices')) return 'List of devices attached\nemulator-5554\tdevice'
|
|
98
|
+
if (cmd.startsWith('xcrun --version')) throw new Error('not found')
|
|
99
|
+
return ''
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const res = await systemStatus.getSystemStatus()
|
|
103
|
+
// Expect iOS check to be false and Android to be healthy
|
|
104
|
+
assert.strictEqual(res.iosAvailable, false)
|
|
105
|
+
assert.strictEqual(res.adbAvailable, true)
|
|
106
|
+
// overall success may still be true (Android ok) but issues should include an xcrun-related message
|
|
107
|
+
assert(res.issues.some((i: string) => i.toLowerCase().includes('xcrun') || i.toLowerCase().includes('ios')))
|
|
108
|
+
})
|
|
109
|
+
})
|