mobile-debug-mcp 0.9.0 → 0.10.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 (48) 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 +4 -17
  6. package/dist/android/interact.js +26 -4
  7. package/dist/android/observe.js +3 -3
  8. package/dist/android/utils.js +59 -104
  9. package/dist/ios/interact.js +3 -3
  10. package/dist/ios/observe.js +4 -4
  11. package/dist/ios/utils.js +8 -8
  12. package/dist/server.js +34 -42
  13. package/dist/tools/install.js +1 -1
  14. package/dist/tools/interact.js +89 -0
  15. package/dist/tools/logs.js +2 -2
  16. package/dist/tools/observe.js +126 -0
  17. package/dist/utils/index.js +1 -0
  18. package/dist/utils/java.js +76 -0
  19. package/docs/CHANGELOG.md +21 -6
  20. package/eslint.config.cjs +36 -0
  21. package/eslint.config.js +60 -0
  22. package/package.json +8 -2
  23. package/src/android/interact.ts +24 -5
  24. package/src/android/observe.ts +3 -3
  25. package/src/android/utils.ts +65 -93
  26. package/src/ios/interact.ts +3 -4
  27. package/src/ios/observe.ts +4 -4
  28. package/src/ios/utils.ts +8 -8
  29. package/src/server.ts +37 -58
  30. package/src/tools/interact.ts +84 -0
  31. package/src/tools/observe.ts +132 -0
  32. package/src/utils/index.ts +1 -0
  33. package/src/utils/java.ts +69 -0
  34. package/test/integration/install.integration.ts +3 -3
  35. package/test/integration/run-install-android.ts +1 -1
  36. package/test/integration/run-install-ios.ts +1 -1
  37. package/test/integration/smoke-test.ts +1 -1
  38. package/test/integration/test-dist.ts +1 -1
  39. package/test/integration/test-ui-tree.ts +1 -1
  40. package/test/integration/wait_for_element_real.ts +1 -1
  41. package/test/unit/detect-java.test.ts +22 -0
  42. package/test/unit/install.test.ts +0 -6
  43. package/src/tools/app.ts +0 -46
  44. package/src/tools/devices.ts +0 -6
  45. package/src/tools/install.ts +0 -43
  46. package/src/tools/logs.ts +0 -62
  47. package/src/tools/screenshot.ts +0 -18
  48. package/src/tools/ui.ts +0 -62
package/dist/server.js CHANGED
@@ -2,20 +2,12 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { AndroidObserve } from "./android/observe.js";
6
- import { AndroidInteract } from "./android/interact.js";
7
- import { iOSObserve } from "./ios/observe.js";
8
- import { iOSInteract } from "./ios/interact.js";
9
- import { installAppHandler } from './tools/install.js';
10
- import { startAppHandler, terminateAppHandler, restartAppHandler, resetAppDataHandler } from './tools/app.js';
11
- import { getLogsHandler, startLogStreamHandler, readLogStreamHandler, stopLogStreamHandler } from './tools/logs.js';
12
- import { listDevicesHandler } from './tools/devices.js';
13
- import { captureScreenshotHandler } from './tools/screenshot.js';
14
- import { getUITreeHandler, getCurrentScreenHandler, waitForElementHandler, tapHandler, swipeHandler, typeTextHandler, pressBackHandler } from './tools/ui.js';
15
- const androidObserve = new AndroidObserve();
16
- const androidInteract = new AndroidInteract();
17
- const iosObserve = new iOSObserve();
18
- const iosInteract = new iOSInteract();
5
+ import { ToolsInteract } from './tools/interact.js';
6
+ import { ToolsObserve } from './tools/observe.js';
7
+ import { AndroidInteract } from './android/interact.js';
8
+ import { iOSInteract } from './ios/interact.js';
9
+ import { AndroidObserve } from './android/observe.js';
10
+ import { iOSObserve } from './ios/observe.js';
19
11
  const server = new Server({
20
12
  name: "mobile-debug-mcp",
21
13
  version: "0.7.0"
@@ -383,46 +375,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
383
375
  try {
384
376
  if (name === "start_app") {
385
377
  const { platform, appId, deviceId } = args;
386
- const result = await startAppHandler({ platform, appId, deviceId });
378
+ const res = await (platform === 'android' ? new AndroidInteract().startApp(appId, deviceId) : new iOSInteract().startApp(appId, deviceId));
387
379
  const response = {
388
- device: result.device,
389
- appStarted: result.appStarted,
390
- launchTimeMs: result.launchTimeMs
380
+ device: res.device,
381
+ appStarted: res.appStarted,
382
+ launchTimeMs: res.launchTimeMs
391
383
  };
392
384
  return wrapResponse(response);
393
385
  }
394
386
  if (name === "terminate_app") {
395
387
  const { platform, appId, deviceId } = args;
396
- const result = await terminateAppHandler({ platform, appId, deviceId });
397
- const response = { device: result.device, appTerminated: result.appTerminated };
388
+ const res = await (platform === 'android' ? new AndroidInteract().terminateApp(appId, deviceId) : new iOSInteract().terminateApp(appId, deviceId));
389
+ const response = { device: res.device, appTerminated: res.appTerminated };
398
390
  return wrapResponse(response);
399
391
  }
400
392
  if (name === "restart_app") {
401
393
  const { platform, appId, deviceId } = args;
402
- const result = await restartAppHandler({ platform, appId, deviceId });
403
- const response = { device: result.device, appRestarted: result.appRestarted, launchTimeMs: result.launchTimeMs };
394
+ const res = await (platform === 'android' ? new AndroidInteract().restartApp(appId, deviceId) : new iOSInteract().restartApp(appId, deviceId));
395
+ const response = { device: res.device, appRestarted: res.appRestarted, launchTimeMs: res.launchTimeMs };
404
396
  return wrapResponse(response);
405
397
  }
406
398
  if (name === "reset_app_data") {
407
399
  const { platform, appId, deviceId } = args;
408
- const result = await resetAppDataHandler({ platform, appId, deviceId });
409
- const response = { device: result.device, dataCleared: result.dataCleared };
400
+ const res = await (platform === 'android' ? new AndroidInteract().resetAppData(appId, deviceId) : new iOSInteract().resetAppData(appId, deviceId));
401
+ const response = { device: res.device, dataCleared: res.dataCleared };
410
402
  return wrapResponse(response);
411
403
  }
412
404
  if (name === "install_app") {
413
405
  const { platform, appPath, deviceId } = args;
414
- const result = await installAppHandler({ platform, appPath, deviceId });
406
+ const res = await ToolsInteract.installAppHandler({ platform, appPath, deviceId });
415
407
  const response = {
416
- device: result.device,
417
- installed: result.installed,
418
- output: result.output,
419
- error: result.error
408
+ device: res.device,
409
+ installed: res.installed,
410
+ output: res.output,
411
+ error: res.error
420
412
  };
421
413
  return wrapResponse(response);
422
414
  }
423
415
  if (name === "get_logs") {
424
416
  const { platform, appId, deviceId, lines } = args;
425
- const res = await getLogsHandler({ platform, appId, deviceId, lines });
417
+ const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines });
426
418
  return {
427
419
  content: [
428
420
  { type: 'text', text: JSON.stringify({ device: res.device, result: { lines: res.logs.length, crashLines: res.crashLines.length > 0 ? res.crashLines : undefined } }, null, 2) },
@@ -432,12 +424,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
432
424
  }
433
425
  if (name === "list_devices") {
434
426
  const { platform, appId } = (args || {});
435
- const res = await listDevicesHandler({ platform, appId });
427
+ const res = await ToolsObserve.listDevicesHandler({ platform, appId });
436
428
  return wrapResponse(res);
437
429
  }
438
430
  if (name === "capture_screenshot") {
439
431
  const { platform, deviceId } = args;
440
- const res = await captureScreenshotHandler({ platform, deviceId });
432
+ const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId });
441
433
  return {
442
434
  content: [
443
435
  { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: res.resolution } }, null, 2) },
@@ -447,52 +439,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
447
439
  }
448
440
  if (name === "get_ui_tree") {
449
441
  const { platform, deviceId } = args;
450
- const res = await getUITreeHandler({ platform, deviceId });
442
+ const res = await (platform === 'android' ? new AndroidObserve().getUITree(deviceId) : new iOSObserve().getUITree(deviceId));
451
443
  return wrapResponse(res);
452
444
  }
453
445
  if (name === "get_current_screen") {
454
446
  const { deviceId } = (args || {});
455
- const res = await getCurrentScreenHandler({ deviceId });
447
+ const res = await new AndroidObserve().getCurrentScreen(deviceId);
456
448
  return wrapResponse(res);
457
449
  }
458
450
  if (name === "wait_for_element") {
459
451
  const { platform, text, timeout, deviceId } = (args || {});
460
- const res = await waitForElementHandler({ platform, text, timeout, deviceId });
452
+ const res = await (platform === 'android' ? new AndroidInteract().waitForElement(text, timeout, deviceId) : new iOSInteract().waitForElement(text, timeout, deviceId));
461
453
  return wrapResponse(res);
462
454
  }
463
455
  if (name === "tap") {
464
456
  const { platform, x, y, deviceId } = (args || {});
465
- const res = await tapHandler({ platform, x, y, deviceId });
457
+ const res = await (platform === 'android' ? new AndroidInteract().tap(x, y, deviceId) : new iOSInteract().tap(x, y, deviceId));
466
458
  return wrapResponse(res);
467
459
  }
468
460
  if (name === "swipe") {
469
461
  const { x1, y1, x2, y2, duration, deviceId } = (args || {});
470
- const res = await swipeHandler({ x1, y1, x2, y2, duration, deviceId });
462
+ const res = await new AndroidInteract().swipe(x1, y1, x2, y2, duration, deviceId);
471
463
  return wrapResponse(res);
472
464
  }
473
465
  if (name === "type_text") {
474
466
  const { text, deviceId } = (args || {});
475
- const res = await typeTextHandler({ text, deviceId });
467
+ const res = await new AndroidInteract().typeText(text, deviceId);
476
468
  return wrapResponse(res);
477
469
  }
478
470
  if (name === "press_back") {
479
471
  const { deviceId } = (args || {});
480
- const res = await pressBackHandler({ deviceId });
472
+ const res = await new AndroidInteract().pressBack(deviceId);
481
473
  return wrapResponse(res);
482
474
  }
483
475
  if (name === 'start_log_stream') {
484
476
  const { platform, packageName, level, sessionId, deviceId } = args;
485
- const res = await startLogStreamHandler({ platform, packageName, level, sessionId, deviceId });
477
+ const res = await ToolsObserve.startLogStreamHandler({ platform, packageName, level, sessionId, deviceId });
486
478
  return wrapResponse(res);
487
479
  }
488
480
  if (name === 'read_log_stream') {
489
481
  const { platform, sessionId, limit, since } = args;
490
- const res = await readLogStreamHandler({ platform, sessionId, limit, since });
482
+ const res = await ToolsObserve.readLogStreamHandler({ platform, sessionId, limit, since });
491
483
  return wrapResponse(res);
492
484
  }
493
485
  if (name === 'stop_log_stream') {
494
486
  const { platform, sessionId } = (args || {});
495
- const res = await stopLogStreamHandler({ platform, sessionId });
487
+ const res = await ToolsObserve.stopLogStreamHandler({ platform, sessionId });
496
488
  return wrapResponse(res);
497
489
  }
498
490
  }
@@ -31,7 +31,7 @@ export async function installAppHandler({ platform, appPath, deviceId }) {
31
31
  chosenPlatform = 'android';
32
32
  }
33
33
  }
34
- catch (e) {
34
+ catch {
35
35
  chosenPlatform = 'android';
36
36
  }
37
37
  if (chosenPlatform === 'android') {
@@ -0,0 +1,89 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { resolveTargetDevice } from '../resolve-device.js';
4
+ import { AndroidInteract } from '../android/interact.js';
5
+ import { iOSInteract } from '../ios/interact.js';
6
+ export class ToolsInteract {
7
+ static async installAppHandler({ platform, appPath, deviceId }) {
8
+ let chosenPlatform = platform;
9
+ try {
10
+ const stat = await fs.stat(appPath).catch(() => null);
11
+ if (stat && stat.isDirectory()) {
12
+ const files = (await fs.readdir(appPath).catch(() => []));
13
+ if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
14
+ chosenPlatform = 'ios';
15
+ }
16
+ 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)))) {
17
+ chosenPlatform = 'android';
18
+ }
19
+ else {
20
+ chosenPlatform = 'android';
21
+ }
22
+ }
23
+ else if (typeof appPath === 'string') {
24
+ const ext = path.extname(appPath).toLowerCase();
25
+ if (ext === '.apk')
26
+ chosenPlatform = 'android';
27
+ else if (ext === '.ipa' || ext === '.app')
28
+ chosenPlatform = 'ios';
29
+ else
30
+ chosenPlatform = 'android';
31
+ }
32
+ }
33
+ catch {
34
+ chosenPlatform = 'android';
35
+ }
36
+ if (chosenPlatform === 'android') {
37
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
38
+ const androidInteract = new AndroidInteract();
39
+ const result = await androidInteract.installApp(appPath, resolved.id);
40
+ return result;
41
+ }
42
+ else {
43
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
44
+ const iosInteract = new iOSInteract();
45
+ const result = await iosInteract.installApp(appPath, resolved.id);
46
+ return result;
47
+ }
48
+ }
49
+ static async startAppHandler({ platform, appId, deviceId }) {
50
+ if (platform === 'android') {
51
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
52
+ return await new AndroidInteract().startApp(appId, resolved.id);
53
+ }
54
+ else {
55
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
56
+ return await new iOSInteract().startApp(appId, resolved.id);
57
+ }
58
+ }
59
+ static async terminateAppHandler({ platform, appId, deviceId }) {
60
+ if (platform === 'android') {
61
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
62
+ return await new AndroidInteract().terminateApp(appId, resolved.id);
63
+ }
64
+ else {
65
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
66
+ return await new iOSInteract().terminateApp(appId, resolved.id);
67
+ }
68
+ }
69
+ static async restartAppHandler({ platform, appId, deviceId }) {
70
+ if (platform === 'android') {
71
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
72
+ return await new AndroidInteract().restartApp(appId, resolved.id);
73
+ }
74
+ else {
75
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
76
+ return await new iOSInteract().restartApp(appId, resolved.id);
77
+ }
78
+ }
79
+ static async resetAppDataHandler({ platform, appId, deviceId }) {
80
+ if (platform === 'android') {
81
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
82
+ return await new AndroidInteract().resetAppData(appId, resolved.id);
83
+ }
84
+ else {
85
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
86
+ return await new iOSInteract().resetAppData(appId, resolved.id);
87
+ }
88
+ }
89
+ }
@@ -23,7 +23,7 @@ export async function getLogsHandler({ platform, appId, deviceId, lines }) {
23
23
  const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
24
24
  return { device: deviceInfo, logs, crashLines };
25
25
  }
26
- catch (e) {
26
+ catch {
27
27
  return { device: deviceInfo, logs: [], crashLines: [] };
28
28
  }
29
29
  }
@@ -37,7 +37,7 @@ export async function startLogStreamHandler({ platform, packageName, level, sess
37
37
  }
38
38
  else {
39
39
  const resolved = await resolveTargetDevice({ platform: 'ios', appId: packageName, deviceId });
40
- return await startIOSLogStream(packageName, level || 'error', resolved.id, sid);
40
+ return await startIOSLogStream(packageName, resolved.id, sid);
41
41
  }
42
42
  }
43
43
  export async function readLogStreamHandler({ platform, sessionId, limit, since }) {
@@ -0,0 +1,126 @@
1
+ import { resolveTargetDevice, listDevices } from '../resolve-device.js';
2
+ import { AndroidObserve } from '../android/observe.js';
3
+ import { iOSObserve } from '../ios/observe.js';
4
+ import { AndroidInteract } from '../android/interact.js';
5
+ import { iOSInteract } from '../ios/interact.js';
6
+ export class ToolsObserve {
7
+ static async getLogsHandler({ platform, appId, deviceId, lines }) {
8
+ if (platform === 'android') {
9
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
10
+ const response = await new AndroidObserve().getLogs(appId, lines ?? 200, resolved.id);
11
+ const logs = Array.isArray(response.logs) ? response.logs : [];
12
+ const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'));
13
+ return { device: response.device, logs, crashLines };
14
+ }
15
+ else {
16
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
17
+ const resp = await new iOSObserve().getLogs(appId, resolved.id);
18
+ const logs = Array.isArray(resp.logs) ? resp.logs : [];
19
+ const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
20
+ return { device: resp.device, logs, crashLines };
21
+ }
22
+ }
23
+ static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
24
+ const effectivePlatform = platform || 'android';
25
+ const sid = sessionId || 'default';
26
+ if (effectivePlatform === 'android') {
27
+ const resolved = await resolveTargetDevice({ platform: 'android', appId: packageName, deviceId });
28
+ // AndroidObserve uses utils for log stream control; delegate to android/utils functions where appropriate
29
+ const { startAndroidLogStream } = await import('../android/utils.js');
30
+ return await startAndroidLogStream(packageName, level || 'error', resolved.id, sid);
31
+ }
32
+ else {
33
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId: packageName, deviceId });
34
+ // iOSObserve implements startIOSLogStream via ios/utils; use its helper
35
+ const { startIOSLogStream } = await import('../ios/utils.js');
36
+ return await startIOSLogStream(packageName, resolved.id, sid);
37
+ }
38
+ }
39
+ static async readLogStreamHandler({ platform, sessionId, limit, since }) {
40
+ const effectivePlatform = platform || 'android';
41
+ const sid = sessionId || 'default';
42
+ if (effectivePlatform === 'android') {
43
+ const { readLogStreamLines } = await import('../android/utils.js');
44
+ return await readLogStreamLines(sid, limit ?? 100, since);
45
+ }
46
+ else {
47
+ const { readIOSLogStreamLines } = await import('../ios/utils.js');
48
+ return await readIOSLogStreamLines(sid, limit ?? 100, since);
49
+ }
50
+ }
51
+ static async stopLogStreamHandler({ platform, sessionId }) {
52
+ const effectivePlatform = platform || 'android';
53
+ const sid = sessionId || 'default';
54
+ if (effectivePlatform === 'android') {
55
+ const { stopAndroidLogStream } = await import('../android/utils.js');
56
+ return await stopAndroidLogStream(sid);
57
+ }
58
+ else {
59
+ const { stopIOSLogStream } = await import('../ios/utils.js');
60
+ return await stopIOSLogStream(sid);
61
+ }
62
+ }
63
+ static async getUITreeHandler({ platform, deviceId }) {
64
+ if (platform === 'android') {
65
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
66
+ return await new AndroidObserve().getUITree(resolved.id);
67
+ }
68
+ else {
69
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
70
+ return await new iOSObserve().getUITree(resolved.id);
71
+ }
72
+ }
73
+ static async getCurrentScreenHandler({ deviceId }) {
74
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
75
+ return await new AndroidObserve().getCurrentScreen(resolved.id);
76
+ }
77
+ static async waitForElementHandler({ platform, text, timeout, deviceId }) {
78
+ const effectiveTimeout = timeout ?? 10000;
79
+ if (platform === 'android') {
80
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
81
+ return await new AndroidInteract().waitForElement(text, effectiveTimeout, resolved.id);
82
+ }
83
+ else {
84
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
85
+ return await new iOSInteract().waitForElement(text, effectiveTimeout, resolved.id);
86
+ }
87
+ }
88
+ static async tapHandler({ platform, x, y, deviceId }) {
89
+ const effectivePlatform = platform || 'android';
90
+ if (effectivePlatform === 'android') {
91
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
92
+ return await new AndroidInteract().tap(x, y, resolved.id);
93
+ }
94
+ else {
95
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
96
+ return await new iOSInteract().tap(x, y, resolved.id);
97
+ }
98
+ }
99
+ static async swipeHandler({ x1, y1, x2, y2, duration, deviceId }) {
100
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
101
+ return await new AndroidInteract().swipe(x1, y1, x2, y2, duration, resolved.id);
102
+ }
103
+ static async typeTextHandler({ text, deviceId }) {
104
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
105
+ return await new AndroidInteract().typeText(text, resolved.id);
106
+ }
107
+ static async pressBackHandler({ deviceId }) {
108
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
109
+ return await new AndroidInteract().pressBack(resolved.id);
110
+ }
111
+ static async listDevicesHandler({ platform, appId }) {
112
+ const devices = await listDevices(platform, appId);
113
+ return { devices };
114
+ }
115
+ static async captureScreenshotHandler({ platform, deviceId }) {
116
+ const effectivePlatform = platform || 'android';
117
+ if (effectivePlatform === 'android') {
118
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
119
+ return await new AndroidObserve().captureScreen(resolved.id);
120
+ }
121
+ else {
122
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
123
+ return await new iOSObserve().captureScreenshot(resolved.id);
124
+ }
125
+ }
126
+ }
@@ -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,29 @@
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.10.0]
6
6
 
7
7
  ### 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.
8
+ - Tools refactor: consolidated handlers into ToolsInteract and ToolsObserve classes to centralise tool wiring and simplify platform delegation.
9
+ - install_app now builds project directories (Gradle/xcodebuild) and supports streamed installs with robust fallbacks (adb push + pm install).
10
+ - Added log streaming utilities and improved log parsing/crash detection heuristics.
11
+ - CI: added lint and unit tests for handler parity; updated README links to docs and changelog.
12
+ - Docs: Created docs/TOOLS.md with comprehensive tool definitions and examples.
13
+
14
+
15
+ ## [0.9.0]
16
+
17
+ ### Added / Changed
18
+ - 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.
19
+ - 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`.
20
+ - 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.
21
+ - 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.
22
+ - 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).
23
+ - Improved types: `execAdb` / `spawnAdb` now accept `SpawnOptionsWithTimeout` (typed extension of Node's SpawnOptions with an optional timeout property).
24
+ - 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.
25
+ - 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.
26
+ - Misc: improved logging, more informative error messages, and several internal cleanups (removed redundant try/catch, consolidated helper functions).
27
+
13
28
 
14
29
  ## [0.8.0]
15
30
 
@@ -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
+ ]