mobile-debug-mcp 0.9.0 → 0.11.0
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/.eslintignore +5 -0
- package/.eslintrc.cjs +18 -0
- package/.github/workflows/.gitkeep +0 -0
- package/.github/workflows/ci.yml +63 -0
- package/README.md +5 -16
- package/dist/android/interact.js +1 -123
- package/dist/android/manage.js +137 -0
- package/dist/android/observe.js +133 -88
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +181 -236
- package/dist/ios/interact.js +1 -168
- package/dist/ios/manage.js +145 -0
- package/dist/ios/observe.js +112 -5
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +19 -118
- package/dist/server.js +44 -42
- package/dist/tools/install.js +1 -1
- package/dist/tools/interact.js +39 -0
- package/dist/tools/logs.js +2 -2
- package/dist/tools/manage.js +180 -0
- package/dist/tools/observe.js +80 -0
- package/dist/tools/run.js +180 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/java.js +76 -0
- package/docs/CHANGELOG.md +25 -6
- package/eslint.config.cjs +36 -0
- package/eslint.config.js +60 -0
- package/package.json +8 -2
- package/src/android/interact.ts +2 -136
- package/src/android/manage.ts +135 -0
- package/src/android/observe.ts +129 -97
- package/src/android/utils.ts +199 -229
- package/src/ios/interact.ts +2 -175
- package/src/ios/manage.ts +143 -0
- package/src/ios/observe.ts +113 -5
- package/src/ios/utils.ts +20 -122
- package/src/server.ts +48 -58
- package/src/tools/interact.ts +45 -0
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +82 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/java.ts +69 -0
- package/test/integration/install.integration.ts +3 -3
- package/test/integration/logstream-real.ts +5 -4
- package/test/integration/run-install-android.ts +1 -1
- package/test/integration/run-install-ios.ts +1 -1
- package/test/integration/smoke-test.ts +1 -1
- package/test/integration/test-dist.ts +1 -1
- package/test/integration/test-ui-tree.ts +1 -1
- package/test/integration/wait_for_element_real.ts +1 -1
- package/test/unit/build.test.ts +84 -0
- package/test/unit/build_and_install.test.ts +132 -0
- package/test/unit/detect-java.test.ts +22 -0
- package/test/unit/install.test.ts +2 -8
- package/test/unit/logstream.test.ts +8 -9
- package/src/tools/app.ts +0 -46
- package/src/tools/devices.ts +0 -6
- package/src/tools/install.ts +0 -43
- package/src/tools/logs.ts +0 -62
- package/src/tools/screenshot.ts +0 -18
- package/src/tools/ui.ts +0 -62
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolveTargetDevice } from '../resolve-device.js';
|
|
2
|
+
import { AndroidObserve } from '../android/observe.js';
|
|
3
|
+
import { iOSObserve } from '../ios/observe.js';
|
|
4
|
+
export class ToolsObserve {
|
|
5
|
+
static async getUITreeHandler({ platform, deviceId }) {
|
|
6
|
+
if (platform === 'android') {
|
|
7
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
8
|
+
return await new AndroidObserve().getUITree(resolved.id);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
|
|
12
|
+
return await new iOSObserve().getUITree(resolved.id);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
static async getCurrentScreenHandler({ deviceId }) {
|
|
16
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
17
|
+
return await new AndroidObserve().getCurrentScreen(resolved.id);
|
|
18
|
+
}
|
|
19
|
+
static async getLogsHandler({ platform, appId, deviceId, lines }) {
|
|
20
|
+
if (platform === 'android') {
|
|
21
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
22
|
+
const response = await new AndroidObserve().getLogs(appId, lines ?? 200, resolved.id);
|
|
23
|
+
const logs = Array.isArray(response.logs) ? response.logs : [];
|
|
24
|
+
const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'));
|
|
25
|
+
return { device: response.device, logs, crashLines };
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
29
|
+
const resp = await new iOSObserve().getLogs(appId, resolved.id);
|
|
30
|
+
const logs = Array.isArray(resp.logs) ? resp.logs : [];
|
|
31
|
+
const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
|
|
32
|
+
return { device: resp.device, logs, crashLines };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
|
|
36
|
+
const effectivePlatform = platform || 'android';
|
|
37
|
+
const sid = sessionId || 'default';
|
|
38
|
+
if (effectivePlatform === 'android') {
|
|
39
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId: packageName, deviceId });
|
|
40
|
+
// Delegate to AndroidObserve's log stream methods
|
|
41
|
+
return await new AndroidObserve().startLogStream(packageName, level || 'error', resolved.id, sid);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId: packageName, deviceId });
|
|
45
|
+
// Delegate to iOSObserve for starting log streams
|
|
46
|
+
return await new iOSObserve().startLogStream(packageName, resolved.id, sid);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
static async readLogStreamHandler({ platform, sessionId, limit, since }) {
|
|
50
|
+
const effectivePlatform = platform || 'android';
|
|
51
|
+
const sid = sessionId || 'default';
|
|
52
|
+
if (effectivePlatform === 'android') {
|
|
53
|
+
return await new AndroidObserve().readLogStream(sid, limit ?? 100, since);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
return await new iOSObserve().readLogStream(sid, limit ?? 100, since);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
static async stopLogStreamHandler({ platform, sessionId }) {
|
|
60
|
+
const effectivePlatform = platform || 'android';
|
|
61
|
+
const sid = sessionId || 'default';
|
|
62
|
+
if (effectivePlatform === 'android') {
|
|
63
|
+
return await new AndroidObserve().stopLogStream(sid);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
return await new iOSObserve().stopLogStream(sid);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
static async captureScreenshotHandler({ platform, deviceId }) {
|
|
70
|
+
const effectivePlatform = platform || 'android';
|
|
71
|
+
if (effectivePlatform === 'android') {
|
|
72
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
73
|
+
return await new AndroidObserve().captureScreen(resolved.id);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
|
|
77
|
+
return await new iOSObserve().captureScreenshot(resolved.id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { resolveTargetDevice, listDevices } from '../resolve-device.js';
|
|
4
|
+
import { AndroidManage } from '../android/manage.js';
|
|
5
|
+
import { iOSManage } from '../ios/manage.js';
|
|
6
|
+
export class ToolsRun {
|
|
7
|
+
static async buildAppHandler({ platform, projectPath, variant }) {
|
|
8
|
+
// delegate to platform-specific build implementations
|
|
9
|
+
const chosen = platform || 'android';
|
|
10
|
+
if (chosen === 'android') {
|
|
11
|
+
const android = new AndroidManage();
|
|
12
|
+
const artifact = await android.build(projectPath, variant);
|
|
13
|
+
return artifact;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const ios = new iOSManage();
|
|
17
|
+
const artifact = await ios.build(projectPath, variant);
|
|
18
|
+
return artifact;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
static async installAppHandler({ platform, appPath, deviceId }) {
|
|
22
|
+
let chosenPlatform = platform;
|
|
23
|
+
try {
|
|
24
|
+
const stat = await fs.stat(appPath).catch(() => null);
|
|
25
|
+
if (stat && stat.isDirectory()) {
|
|
26
|
+
// If the directory itself looks like an .app bundle, treat as iOS
|
|
27
|
+
if (appPath.endsWith('.app')) {
|
|
28
|
+
chosenPlatform = 'ios';
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const files = (await fs.readdir(appPath).catch(() => []));
|
|
32
|
+
if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
|
|
33
|
+
chosenPlatform = 'ios';
|
|
34
|
+
}
|
|
35
|
+
else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
|
|
36
|
+
chosenPlatform = 'android';
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
chosenPlatform = 'android';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (typeof appPath === 'string') {
|
|
44
|
+
const ext = path.extname(appPath).toLowerCase();
|
|
45
|
+
if (ext === '.apk')
|
|
46
|
+
chosenPlatform = 'android';
|
|
47
|
+
else if (ext === '.ipa' || ext === '.app')
|
|
48
|
+
chosenPlatform = 'ios';
|
|
49
|
+
else
|
|
50
|
+
chosenPlatform = 'android';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
chosenPlatform = 'android';
|
|
55
|
+
}
|
|
56
|
+
if (chosenPlatform === 'android') {
|
|
57
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
58
|
+
const androidRun = new AndroidManage();
|
|
59
|
+
const result = await androidRun.installApp(appPath, resolved.id);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
|
|
64
|
+
const iosRun = new iOSManage();
|
|
65
|
+
const result = await iosRun.installApp(appPath, resolved.id);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
static async startAppHandler({ platform, appId, deviceId }) {
|
|
70
|
+
if (platform === 'android') {
|
|
71
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
72
|
+
return await new AndroidManage().startApp(appId, resolved.id);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
76
|
+
return await new iOSManage().startApp(appId, resolved.id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
static async terminateAppHandler({ platform, appId, deviceId }) {
|
|
80
|
+
if (platform === 'android') {
|
|
81
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
82
|
+
return await new AndroidManage().terminateApp(appId, resolved.id);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
86
|
+
return await new iOSManage().terminateApp(appId, resolved.id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
static async restartAppHandler({ platform, appId, deviceId }) {
|
|
90
|
+
if (platform === 'android') {
|
|
91
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
92
|
+
return await new AndroidManage().restartApp(appId, resolved.id);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
96
|
+
return await new iOSManage().restartApp(appId, resolved.id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
static async resetAppDataHandler({ platform, appId, deviceId }) {
|
|
100
|
+
if (platform === 'android') {
|
|
101
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
102
|
+
return await new AndroidManage().resetAppData(appId, resolved.id);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
106
|
+
return await new iOSManage().resetAppData(appId, resolved.id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
|
|
110
|
+
const events = [];
|
|
111
|
+
const pushEvent = (obj) => events.push(JSON.stringify(obj));
|
|
112
|
+
const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
|
|
113
|
+
void effectiveTimeout;
|
|
114
|
+
// determine platform if not provided by inspecting path
|
|
115
|
+
let chosenPlatform = platform;
|
|
116
|
+
try {
|
|
117
|
+
const stat = await fs.stat(projectPath).catch(() => null);
|
|
118
|
+
if (!chosenPlatform) {
|
|
119
|
+
if (stat && stat.isDirectory()) {
|
|
120
|
+
const files = (await fs.readdir(projectPath).catch(() => []));
|
|
121
|
+
if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')))
|
|
122
|
+
chosenPlatform = 'ios';
|
|
123
|
+
else
|
|
124
|
+
chosenPlatform = 'android';
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const ext = path.extname(projectPath).toLowerCase();
|
|
128
|
+
if (ext === '.apk')
|
|
129
|
+
chosenPlatform = 'android';
|
|
130
|
+
else if (ext === '.ipa' || ext === '.app')
|
|
131
|
+
chosenPlatform = 'ios';
|
|
132
|
+
else
|
|
133
|
+
chosenPlatform = 'android';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
chosenPlatform = chosenPlatform || 'android';
|
|
139
|
+
}
|
|
140
|
+
pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
|
|
141
|
+
let buildRes;
|
|
142
|
+
try {
|
|
143
|
+
buildRes = await ToolsRun.buildAppHandler({ platform: chosenPlatform, projectPath });
|
|
144
|
+
if (buildRes && buildRes.error) {
|
|
145
|
+
pushEvent({ type: 'build', status: 'failed', error: buildRes.error });
|
|
146
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: buildRes.error } };
|
|
147
|
+
}
|
|
148
|
+
pushEvent({ type: 'build', status: 'finished', artifactPath: buildRes.artifactPath });
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
152
|
+
pushEvent({ type: 'build', status: 'failed', error: msg });
|
|
153
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
|
|
154
|
+
}
|
|
155
|
+
// Install phase
|
|
156
|
+
const artifact = buildRes.artifactPath || projectPath;
|
|
157
|
+
pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
|
|
158
|
+
let installRes;
|
|
159
|
+
try {
|
|
160
|
+
installRes = await ToolsRun.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId });
|
|
161
|
+
if (installRes && installRes.installed === true) {
|
|
162
|
+
pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
|
|
163
|
+
return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
pushEvent({ type: 'install', status: 'failed', error: installRes.error || 'unknown' });
|
|
167
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: installRes.error || 'install failed' } };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
172
|
+
pushEvent({ type: 'install', status: 'failed', error: msg });
|
|
173
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
static async listDevicesHandler({ platform, appId }) {
|
|
177
|
+
const devices = await listDevices(platform, appId);
|
|
178
|
+
return { devices };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { detectJavaHome } from './java.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
export async function detectJavaHome() {
|
|
5
|
+
try {
|
|
6
|
+
// If JAVA_HOME is set, validate it's Java 17
|
|
7
|
+
if (process.env.JAVA_HOME) {
|
|
8
|
+
try {
|
|
9
|
+
const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java');
|
|
10
|
+
const v = execSync(`"${javaBin}" -version`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
11
|
+
if (/\b17\b/.test(v) || /17\./.test(v))
|
|
12
|
+
return process.env.JAVA_HOME;
|
|
13
|
+
console.debug('[java.detect] Existing JAVA_HOME does not appear to be Java 17, will search for JDK17');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK17');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// macOS explicit path
|
|
20
|
+
const explicit = '/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home';
|
|
21
|
+
if (existsSync(explicit))
|
|
22
|
+
return explicit;
|
|
23
|
+
// Android Studio JBR candidates
|
|
24
|
+
const jbrCandidates = [
|
|
25
|
+
'/Applications/Android Studio.app/Contents/jbr',
|
|
26
|
+
'/Applications/Android Studio Preview.app/Contents/jbr',
|
|
27
|
+
'/Applications/Android Studio Preview 2022.3.app/Contents/jbr',
|
|
28
|
+
'/Applications/Android Studio Preview 2023.1.app/Contents/jbr'
|
|
29
|
+
];
|
|
30
|
+
for (const p of jbrCandidates) {
|
|
31
|
+
const javaBin = path.join(p, 'bin', 'java');
|
|
32
|
+
if (existsSync(javaBin)) {
|
|
33
|
+
try {
|
|
34
|
+
const v = execSync(`"${javaBin}" -version`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
35
|
+
if (/\b17\b/.test(v) || /17\./.test(v))
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// macOS /usr/libexec/java_home
|
|
42
|
+
try {
|
|
43
|
+
const out = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
|
|
44
|
+
if (out)
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
// macOS common JDK locations
|
|
49
|
+
try {
|
|
50
|
+
const homes = execSync('ls -1 /Library/Java/JavaVirtualMachines || true', { stdio: ['ignore', 'pipe', 'inherit'] }).toString().split(/\r?\n/).filter(Boolean);
|
|
51
|
+
for (const h of homes) {
|
|
52
|
+
if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17')) {
|
|
53
|
+
const candidate = `/Library/Java/JavaVirtualMachines/${h}/Contents/Home`;
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
// Linux locations
|
|
60
|
+
const linuxCandidates = [
|
|
61
|
+
'/usr/lib/jvm/java-17-openjdk-amd64',
|
|
62
|
+
'/usr/lib/jvm/java-17-openjdk',
|
|
63
|
+
'/usr/lib/jvm/zulu17',
|
|
64
|
+
'/usr/lib/jvm/temurin-17-jdk'
|
|
65
|
+
];
|
|
66
|
+
for (const p of linuxCandidates) {
|
|
67
|
+
try {
|
|
68
|
+
if (existsSync(p))
|
|
69
|
+
return p;
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,14 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.11.0]
|
|
6
|
+
- Tools refactor - broke functions into 3 distinct class types; interact (for UI manipulation), manage (for build, installing etc) and observe (observing the app whilst running)
|
|
7
|
+
- Add convenience method to build and install
|
|
8
|
+
|
|
9
|
+
## [0.10.0]
|
|
10
|
+
|
|
11
|
+
### Added / Changed
|
|
12
|
+
- Tools refactor: consolidated handlers into ToolsInteract and ToolsObserve classes to centralise tool wiring and simplify platform delegation.
|
|
13
|
+
- install_app now builds project directories (Gradle/xcodebuild) and supports streamed installs with robust fallbacks (adb push + pm install).
|
|
14
|
+
- Added log streaming utilities and improved log parsing/crash detection heuristics.
|
|
15
|
+
- CI: added lint and unit tests for handler parity; updated README links to docs and changelog.
|
|
16
|
+
- Docs: Created docs/TOOLS.md with comprehensive tool definitions and examples.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [0.9.0]
|
|
6
20
|
|
|
7
21
|
### Added / Changed
|
|
8
|
-
- install_app now builds apps when given a project directory and then installs the produced artifact (Android: Gradle wrapper assembleDebug; iOS: xcodebuild where applicable).
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
22
|
+
- install_app now builds apps when given a project directory and then installs the produced artifact (Android: Gradle wrapper assembleDebug; iOS: xcodebuild where applicable). When a workspace (.xcworkspace) is present, the iOS build uses `-workspace` instead of `-project` to support CocoaPods and multi-project setups.
|
|
23
|
+
- Build orchestration uses a scoped JAVA_HOME (detectJavaHome) and prefers JDK 17 when available; Gradle invocations avoid mutating global env and pass java home via `-Dorg.gradle.java.home`.
|
|
24
|
+
- Streaming ADB support: added `spawnAdb()` (streams stdout/stderr and returns exit code) alongside `execAdb()` (returns buffered stdout). This enables live install output and robust fallbacks.
|
|
25
|
+
- Resilient install flow: streamed `adb install` is attempted first; on failure MCP falls back to `adb push` + `pm install -r` to improve reliability on devices that don't support streamed install or when install times out.
|
|
26
|
+
- Centralised timeout logic: extracted `getAdbTimeout(args, customTimeout)` to standardise timeout selection (precedence: custom timeout > MCP_ADB_TIMEOUT/ADB_TIMEOUT env > per-command defaults — install: 120s, logcat: 10s, uiautomator dump: 20s).
|
|
27
|
+
- Improved types: `execAdb` / `spawnAdb` now accept `SpawnOptionsWithTimeout` (typed extension of Node's SpawnOptions with an optional timeout property).
|
|
28
|
+
- Linting and CI: added ESLint (unused-imports plugin), added `npm run lint` / `npm run lint:fix` scripts, and updated CI to run lint in the unit job. ESLint config converted to the flat `eslint.config.js` format.
|
|
29
|
+
- Tests: unit tests updated to exercise real build/install flows using fake `adb` and `gradlew` wrappers; added detectJavaHome smoke tests. Integration workflows remain manual and require device/emulator access.
|
|
30
|
+
- Misc: improved logging, more informative error messages, and several internal cleanups (removed redundant try/catch, consolidated helper functions).
|
|
31
|
+
|
|
13
32
|
|
|
14
33
|
## [0.8.0]
|
|
15
34
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module.exports = [
|
|
2
|
+
// Files/directories to ignore
|
|
3
|
+
{
|
|
4
|
+
ignores: [
|
|
5
|
+
'dist/',
|
|
6
|
+
'node_modules/',
|
|
7
|
+
'.git/',
|
|
8
|
+
'.vscode/',
|
|
9
|
+
'coverage/',
|
|
10
|
+
'.env'
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
// Apply rules to JS/TS source and tests
|
|
14
|
+
{
|
|
15
|
+
files: ['src/**/*.ts', 'test/**/*.ts', 'src/**/*.js', 'test/**/*.js'],
|
|
16
|
+
languageOptions: {
|
|
17
|
+
parser: require.resolve('@typescript-eslint/parser'),
|
|
18
|
+
parserOptions: {
|
|
19
|
+
ecmaVersion: 2020,
|
|
20
|
+
sourceType: 'module',
|
|
21
|
+
project: './tsconfig.json'
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
plugins: {
|
|
25
|
+
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
|
|
26
|
+
'unused-imports': require('eslint-plugin-unused-imports')
|
|
27
|
+
},
|
|
28
|
+
rules: {
|
|
29
|
+
// Use plugin to error on unused imports and provide autofix where possible
|
|
30
|
+
'unused-imports/no-unused-imports': 'error',
|
|
31
|
+
'unused-imports/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
|
|
32
|
+
// Disable the default TS rule to avoid duplicate warnings
|
|
33
|
+
'@typescript-eslint/no-unused-vars': 'off'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
]
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import tsParser from '@typescript-eslint/parser'
|
|
2
|
+
import tsPlugin from '@typescript-eslint/eslint-plugin'
|
|
3
|
+
import unusedImports from 'eslint-plugin-unused-imports'
|
|
4
|
+
|
|
5
|
+
export default [
|
|
6
|
+
// Files/directories to ignore
|
|
7
|
+
{
|
|
8
|
+
ignores: [
|
|
9
|
+
'dist/',
|
|
10
|
+
'node_modules/',
|
|
11
|
+
'.git/',
|
|
12
|
+
'.vscode/',
|
|
13
|
+
'coverage/',
|
|
14
|
+
'.env'
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
// Apply rules to JS/TS source
|
|
18
|
+
{
|
|
19
|
+
files: ['src/**/*.ts', 'src/**/*.js'],
|
|
20
|
+
languageOptions: {
|
|
21
|
+
parser: tsParser,
|
|
22
|
+
parserOptions: {
|
|
23
|
+
ecmaVersion: 2020,
|
|
24
|
+
sourceType: 'module',
|
|
25
|
+
project: './tsconfig.json'
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
plugins: {
|
|
29
|
+
'@typescript-eslint': tsPlugin,
|
|
30
|
+
'unused-imports': unusedImports
|
|
31
|
+
},
|
|
32
|
+
rules: {
|
|
33
|
+
// Use plugin to error on unused imports and provide autofix where possible
|
|
34
|
+
'unused-imports/no-unused-imports': 'error',
|
|
35
|
+
'unused-imports/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
|
|
36
|
+
// Disable the default TS rule to avoid duplicate warnings
|
|
37
|
+
'@typescript-eslint/no-unused-vars': 'off'
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
// Apply lighter rules to test files (no project reference to avoid TS project parsing)
|
|
41
|
+
{
|
|
42
|
+
files: ['test/**/*.ts', 'test/**/*.js'],
|
|
43
|
+
languageOptions: {
|
|
44
|
+
parser: tsParser,
|
|
45
|
+
parserOptions: {
|
|
46
|
+
ecmaVersion: 2020,
|
|
47
|
+
sourceType: 'module'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
plugins: {
|
|
51
|
+
'@typescript-eslint': tsPlugin,
|
|
52
|
+
'unused-imports': unusedImports
|
|
53
|
+
},
|
|
54
|
+
rules: {
|
|
55
|
+
'unused-imports/no-unused-imports': 'error',
|
|
56
|
+
'unused-imports/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
|
|
57
|
+
'@typescript-eslint/no-unused-vars': 'off'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-debug-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"prepare": "npm run build",
|
|
13
13
|
"test:unit": "tsx test/unit/index.ts",
|
|
14
14
|
"test:integration": "tsx test/integration/index.ts",
|
|
15
|
-
"test": "npm run test:unit && npm run test:integration"
|
|
15
|
+
"test": "npm run test:unit && npm run test:integration",
|
|
16
|
+
"lint": "eslint --ext .ts,.js src test --quiet",
|
|
17
|
+
"lint:fix": "eslint --ext .ts,.js src test --fix"
|
|
16
18
|
},
|
|
17
19
|
"engines": {
|
|
18
20
|
"node": ">=18"
|
|
@@ -24,6 +26,10 @@
|
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
26
28
|
"@types/node": "^25.4.0",
|
|
29
|
+
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
|
30
|
+
"@typescript-eslint/parser": "^8.57.0",
|
|
31
|
+
"eslint": "^9.39.4",
|
|
32
|
+
"eslint-plugin-unused-imports": "^4.4.1",
|
|
27
33
|
"tsx": "^4.21.0",
|
|
28
34
|
"typescript": "^5.9.3"
|
|
29
35
|
}
|
package/src/android/interact.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo
|
|
1
|
+
import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
|
+
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
|
|
3
3
|
import { AndroidObserve } from "./observe.js"
|
|
4
|
-
import { promises as fs } from "fs"
|
|
5
|
-
import { spawn, execSync } from "child_process"
|
|
6
|
-
import path from "path"
|
|
7
|
-
import { existsSync } from "fs"
|
|
8
4
|
|
|
9
5
|
|
|
10
6
|
export class AndroidInteract {
|
|
@@ -92,134 +88,4 @@ export class AndroidInteract {
|
|
|
92
88
|
}
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
async installApp(apkPath: string, deviceId?: string): Promise<import("../types.js").InstallAppResponse> {
|
|
96
|
-
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
97
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
98
|
-
|
|
99
|
-
// Helper to recursively find first APK under a directory
|
|
100
|
-
async function findApk(dir: string): Promise<string | undefined> {
|
|
101
|
-
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
102
|
-
for (const e of entries) {
|
|
103
|
-
const full = path.join(dir, e.name)
|
|
104
|
-
if (e.isDirectory()) {
|
|
105
|
-
const found = await findApk(full)
|
|
106
|
-
if (found) return found
|
|
107
|
-
} else if (e.isFile() && full.endsWith('.apk')) {
|
|
108
|
-
return full
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return undefined
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
let apkToInstall = apkPath
|
|
116
|
-
|
|
117
|
-
// If a directory is provided, attempt to build via Gradle
|
|
118
|
-
const stat = await fs.stat(apkPath).catch(() => null)
|
|
119
|
-
if (stat && stat.isDirectory()) {
|
|
120
|
-
const gradlewPath = path.join(apkPath, 'gradlew')
|
|
121
|
-
const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle'
|
|
122
|
-
|
|
123
|
-
await new Promise<void>(async (resolve, reject) => {
|
|
124
|
-
// Auto-detect and set JAVA_HOME (prefer JDK 17) so builds don't require manual environment setup
|
|
125
|
-
const detectedJavaHome = await detectJavaHome().catch(() => undefined)
|
|
126
|
-
const env = Object.assign({}, process.env)
|
|
127
|
-
if (detectedJavaHome) {
|
|
128
|
-
// Override existing JAVA_HOME if detection found a preferably compatible JDK (e.g., JDK 17).
|
|
129
|
-
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
130
|
-
env.JAVA_HOME = detectedJavaHome
|
|
131
|
-
// Also ensure the JDK bin is on PATH so tools like jlink/javac are resolved from the detected JDK
|
|
132
|
-
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
|
|
133
|
-
console.debug('[android] Overriding JAVA_HOME with detected path:', detectedJavaHome)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Sanitize environment so user shell init scripts are less likely to override our JAVA_HOME.
|
|
138
|
-
try {
|
|
139
|
-
// Remove obvious shell profile hints; avoid touching SDKMAN symlinks or on-disk state.
|
|
140
|
-
delete env.SHELL
|
|
141
|
-
} catch (e) {}
|
|
142
|
-
|
|
143
|
-
// If we detected a compatible JDK, instruct Gradle to use it and avoid daemon reuse
|
|
144
|
-
// Prepare gradle invocation
|
|
145
|
-
const gradleArgs = ['assembleDebug']
|
|
146
|
-
if (detectedJavaHome) {
|
|
147
|
-
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
|
|
148
|
-
gradleArgs.push('--no-daemon')
|
|
149
|
-
env.GRADLE_JAVA_HOME = detectedJavaHome
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Prefer invoking the wrapper directly without a shell to avoid user profile shims (sdkman) re-setting JAVA_HOME
|
|
153
|
-
const wrapperPath = path.join(apkPath, 'gradlew')
|
|
154
|
-
const useWrapper = existsSync(wrapperPath)
|
|
155
|
-
const execCmd = useWrapper ? wrapperPath : gradleCmd
|
|
156
|
-
const spawnOpts: any = { cwd: apkPath, env }
|
|
157
|
-
// When using wrapper, ensure it's executable and invoke directly (no shell)
|
|
158
|
-
if (useWrapper) {
|
|
159
|
-
// Ensure the wrapper is executable; swallow errors from chmod (best-effort).
|
|
160
|
-
await fs.chmod(wrapperPath, 0o755).catch(() => {})
|
|
161
|
-
spawnOpts.shell = false
|
|
162
|
-
} else {
|
|
163
|
-
// if using system 'gradle' allow shell to resolve platform PATH
|
|
164
|
-
spawnOpts.shell = true
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
168
|
-
let stderr = ''
|
|
169
|
-
proc.stderr?.on('data', d => stderr += d.toString())
|
|
170
|
-
proc.on('close', code => {
|
|
171
|
-
if (code === 0) resolve()
|
|
172
|
-
else reject(new Error(stderr || `Gradle build failed with code ${code}`))
|
|
173
|
-
})
|
|
174
|
-
proc.on('error', err => reject(err))
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
const built = await findApk(apkPath)
|
|
178
|
-
if (!built) throw new Error('Could not locate built APK after running Gradle')
|
|
179
|
-
apkToInstall = built
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const output = await execAdb(['install', '-r', apkToInstall], deviceId)
|
|
183
|
-
return { device: deviceInfo, installed: true, output }
|
|
184
|
-
} catch (e) {
|
|
185
|
-
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) }
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
190
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
191
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
192
|
-
|
|
193
|
-
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
194
|
-
|
|
195
|
-
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
|
|
199
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
200
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
201
|
-
|
|
202
|
-
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
203
|
-
|
|
204
|
-
return { device: deviceInfo, appTerminated: true }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
208
|
-
await this.terminateApp(appId, deviceId)
|
|
209
|
-
const startResult = await this.startApp(appId, deviceId)
|
|
210
|
-
return {
|
|
211
|
-
device: startResult.device,
|
|
212
|
-
appRestarted: startResult.appStarted,
|
|
213
|
-
launchTimeMs: startResult.launchTimeMs
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
|
|
218
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
219
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
220
|
-
|
|
221
|
-
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
222
|
-
|
|
223
|
-
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
224
|
-
}
|
|
225
91
|
}
|