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.
Files changed (61) hide show
  1. package/.eslintignore +5 -0
  2. package/.eslintrc.cjs +18 -0
  3. package/.github/workflows/.gitkeep +0 -0
  4. package/.github/workflows/ci.yml +63 -0
  5. package/README.md +5 -16
  6. package/dist/android/interact.js +1 -123
  7. package/dist/android/manage.js +137 -0
  8. package/dist/android/observe.js +133 -88
  9. package/dist/android/run.js +187 -0
  10. package/dist/android/utils.js +181 -236
  11. package/dist/ios/interact.js +1 -168
  12. package/dist/ios/manage.js +145 -0
  13. package/dist/ios/observe.js +112 -5
  14. package/dist/ios/run.js +200 -0
  15. package/dist/ios/utils.js +19 -118
  16. package/dist/server.js +44 -42
  17. package/dist/tools/install.js +1 -1
  18. package/dist/tools/interact.js +39 -0
  19. package/dist/tools/logs.js +2 -2
  20. package/dist/tools/manage.js +180 -0
  21. package/dist/tools/observe.js +80 -0
  22. package/dist/tools/run.js +180 -0
  23. package/dist/utils/index.js +1 -0
  24. package/dist/utils/java.js +76 -0
  25. package/docs/CHANGELOG.md +25 -6
  26. package/eslint.config.cjs +36 -0
  27. package/eslint.config.js +60 -0
  28. package/package.json +8 -2
  29. package/src/android/interact.ts +2 -136
  30. package/src/android/manage.ts +135 -0
  31. package/src/android/observe.ts +129 -97
  32. package/src/android/utils.ts +199 -229
  33. package/src/ios/interact.ts +2 -175
  34. package/src/ios/manage.ts +143 -0
  35. package/src/ios/observe.ts +113 -5
  36. package/src/ios/utils.ts +20 -122
  37. package/src/server.ts +48 -58
  38. package/src/tools/interact.ts +45 -0
  39. package/src/tools/manage.ts +171 -0
  40. package/src/tools/observe.ts +82 -0
  41. package/src/utils/index.ts +1 -0
  42. package/src/utils/java.ts +69 -0
  43. package/test/integration/install.integration.ts +3 -3
  44. package/test/integration/logstream-real.ts +5 -4
  45. package/test/integration/run-install-android.ts +1 -1
  46. package/test/integration/run-install-ios.ts +1 -1
  47. package/test/integration/smoke-test.ts +1 -1
  48. package/test/integration/test-dist.ts +1 -1
  49. package/test/integration/test-ui-tree.ts +1 -1
  50. package/test/integration/wait_for_element_real.ts +1 -1
  51. package/test/unit/build.test.ts +84 -0
  52. package/test/unit/build_and_install.test.ts +132 -0
  53. package/test/unit/detect-java.test.ts +22 -0
  54. package/test/unit/install.test.ts +2 -8
  55. package/test/unit/logstream.test.ts +8 -9
  56. package/src/tools/app.ts +0 -46
  57. package/src/tools/devices.ts +0 -6
  58. package/src/tools/install.ts +0 -43
  59. package/src/tools/logs.ts +0 -62
  60. package/src/tools/screenshot.ts +0 -18
  61. 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.9.0] - 2026-03-14
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
- - Auto-detects and prefers JDK 17 (Android Studio JBR or system JDK). Any JAVA_HOME overrides are scoped to the spawned build process, avoiding global system changes.
10
- - Respects ADB_PATH and falls back to PATH if unset. Set ADB_PATH to an explicit adb binary to avoid PATH discovery issues.
11
- - Increased default adb timeout to 120s for installs; timeout can be configured via MCP_ADB_TIMEOUT or ADB_TIMEOUT environment variables.
12
- - Improved logging and error messages for build/install steps. Unit and integration tests updated and converted to TypeScript.
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
+ ]
@@ -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.9.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
  }
@@ -1,10 +1,6 @@
1
- import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
- import { execAdb, getAndroidDeviceMetadata, getDeviceInfo, detectJavaHome } from "./utils.js"
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
  }