mobile-debug-mcp 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { iOSInteract } from './ios.js';
3
3
  export { AndroidInteract, iOSInteract };
4
4
  import { resolveTargetDevice } from '../utils/resolve-device.js';
5
5
  import { ToolsObserve } from '../observe/index.js';
6
+ const STABLE_IDLE_MS = 1000;
6
7
  export class ToolsInteract {
7
8
  static async getInteractionService(platform, deviceId) {
8
9
  const effectivePlatform = platform || 'android';
@@ -258,4 +259,167 @@ export class ToolsInteract {
258
259
  }
259
260
  return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start };
260
261
  }
262
+ static async observeUntilHandler({ type, query, timeoutMs = 5000, pollIntervalMs = 200, platform, deviceId }) {
263
+ const start = Date.now();
264
+ const deadline = start + (timeoutMs || 0);
265
+ const q = (query === null || query === undefined) ? '' : String(query);
266
+ // Baseline state
267
+ let initialFingerprint = null;
268
+ try {
269
+ const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
270
+ initialFingerprint = fpRes?.fingerprint ?? null;
271
+ }
272
+ catch (err) {
273
+ console.error('observeUntil: error getting initial fingerprint', err);
274
+ initialFingerprint = null;
275
+ }
276
+ // For logs, capture a baseline snapshot (count or last line) to avoid matching historical lines
277
+ let baselineLastLine = null;
278
+ try {
279
+ const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 });
280
+ const logsArr = Array.isArray(gl.logs) ? gl.logs : [];
281
+ baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null;
282
+ }
283
+ catch (err) {
284
+ // non-fatal but surface warning to aid debugging
285
+ try {
286
+ console.warn('observeUntil: failed to get baseline logs (non-fatal):', err instanceof Error ? err.message : String(err));
287
+ }
288
+ catch { }
289
+ }
290
+ let lastChangeAt = Date.now();
291
+ let prevFingerprint = initialFingerprint;
292
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
293
+ // Telemetry
294
+ let pollCount = 0;
295
+ let timeToMatch = null;
296
+ let matchSource = null;
297
+ while (Date.now() <= deadline) {
298
+ pollCount++;
299
+ try {
300
+ if (type === 'ui') {
301
+ // fast findElement with short timeout to avoid blocking
302
+ try {
303
+ const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId });
304
+ if (found && found.found) {
305
+ timeToMatch = Date.now() - start;
306
+ // determine matchSource heuristics
307
+ const el = found.element || {};
308
+ if (el && el.resourceId && String(el.resourceId).toLowerCase().includes(q.toLowerCase()))
309
+ matchSource = 'ui-resourceId';
310
+ else if (el && el.text && String(el.text).toLowerCase() === q.toLowerCase())
311
+ matchSource = 'ui-exact';
312
+ else
313
+ matchSource = 'ui-partial';
314
+ return { success: true, type: 'ui', matched: true, details: `UI element matched '${q}'`, timestamp: Date.now(), element: found.element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } };
315
+ }
316
+ }
317
+ catch (err) {
318
+ console.error('observeUntil(ui) find error:', err);
319
+ }
320
+ }
321
+ else if (type === 'log') {
322
+ try {
323
+ // Try reading from active stream first
324
+ const stream = await ToolsObserve.readLogStreamHandler({ platform, sessionId: 'default', limit: 200 });
325
+ const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : [];
326
+ for (const ent of entries) {
327
+ const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : '';
328
+ if (q && String(msg).includes(q)) {
329
+ timeToMatch = Date.now() - start;
330
+ matchSource = 'log-stream';
331
+ return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: msg, raw: ent }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } };
332
+ }
333
+ }
334
+ // Fallback to snapshot logs
335
+ const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 });
336
+ const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : [];
337
+ // Only consider new lines after baselineLastLine when possible
338
+ let startIndex = 0;
339
+ if (baselineLastLine) {
340
+ const idx = logsArr.lastIndexOf(baselineLastLine);
341
+ startIndex = idx >= 0 ? idx + 1 : 0;
342
+ }
343
+ for (let i = startIndex; i < logsArr.length; i++) {
344
+ const line = logsArr[i];
345
+ if (q && String(line).includes(q)) {
346
+ timeToMatch = Date.now() - start;
347
+ matchSource = 'log-snapshot';
348
+ return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: line }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } };
349
+ }
350
+ }
351
+ }
352
+ catch (err) {
353
+ console.error('observeUntil(log) error:', err);
354
+ }
355
+ }
356
+ else if (type === 'screen') {
357
+ try {
358
+ const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
359
+ const fp = fpRes?.fingerprint ?? null;
360
+ if (fp !== null && fp !== undefined && fp !== initialFingerprint) {
361
+ if (q) {
362
+ // optionally validate query against new screen context
363
+ try {
364
+ const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId });
365
+ if (found && found.found) {
366
+ timeToMatch = Date.now() - start;
367
+ matchSource = 'screen-validated-ui';
368
+ return { success: true, type: 'screen', matched: true, details: `Screen changed and query matched on new screen`, timestamp: Date.now(), newFingerprint: fp, element: found.element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } };
369
+ }
370
+ }
371
+ catch (err) {
372
+ console.error('observeUntil(screen) find error:', err);
373
+ }
374
+ // If query provided but not matched yet, continue polling until timeout
375
+ }
376
+ else {
377
+ timeToMatch = Date.now() - start;
378
+ matchSource = 'screen-fingerprint';
379
+ return { success: true, type: 'screen', matched: true, details: 'Screen fingerprint changed', timestamp: Date.now(), newFingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } };
380
+ }
381
+ }
382
+ }
383
+ catch (err) {
384
+ console.error('observeUntil(screen) error:', err);
385
+ }
386
+ }
387
+ else if (type === 'idle') {
388
+ try {
389
+ const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
390
+ const fp = fpRes?.fingerprint ?? null;
391
+ if (fp !== prevFingerprint) {
392
+ prevFingerprint = fp;
393
+ lastChangeAt = Date.now();
394
+ }
395
+ else {
396
+ if (Date.now() - lastChangeAt >= STABLE_IDLE_MS) {
397
+ timeToMatch = Date.now() - start;
398
+ matchSource = 'idle-stable';
399
+ return { success: true, type: 'idle', matched: true, details: `UI stable for ${STABLE_IDLE_MS}ms`, timestamp: Date.now(), fingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } };
400
+ }
401
+ }
402
+ }
403
+ catch (err) {
404
+ console.error('observeUntil(idle) error:', err);
405
+ }
406
+ }
407
+ }
408
+ catch (err) {
409
+ console.error('observeUntil: unexpected error', err);
410
+ }
411
+ // Respect poll interval and avoid tight loop
412
+ await sleep(pollIntervalMs || 200);
413
+ }
414
+ // On timeout, capture a failure snapshot to aid debugging (best-effort)
415
+ let snapshot = null;
416
+ try {
417
+ snapshot = await ToolsObserve.captureDebugSnapshotHandler({ reason: `observe_until timeout for ${type}`, includeLogs: true, platform, deviceId });
418
+ }
419
+ catch (err) {
420
+ snapshot = { error: err instanceof Error ? err.message : String(err) };
421
+ }
422
+ const elapsed = Date.now() - start;
423
+ return { success: false, error: 'Timeout waiting for condition', type, timeoutMs, telemetry: { pollCount, elapsedMs: elapsed, matchSource: null }, snapshot };
424
+ }
261
425
  }
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { ToolsInteract } from './interact/index.js';
7
7
  import { ToolsObserve } from './observe/index.js';
8
8
  import { AndroidManage } from './manage/index.js';
9
9
  import { iOSManage } from './manage/index.js';
10
+ import { ensureAdbAvailable } from './utils/android/utils.js';
10
11
  const server = new Server({
11
12
  name: "mobile-debug-mcp",
12
13
  version: "0.7.0"
@@ -15,6 +16,24 @@ const server = new Server({
15
16
  tools: {}
16
17
  }
17
18
  });
19
+ // Startup healthchecks (non-fatal) — verify adb availability and log chosen command
20
+ (async () => {
21
+ try {
22
+ const adbCheck = ensureAdbAvailable();
23
+ if (adbCheck.ok)
24
+ console.debug('[startup] adb available:', adbCheck.adbCmd, adbCheck.version);
25
+ else
26
+ console.warn('[startup] adb not available or failed to run:', adbCheck.adbCmd, adbCheck.error);
27
+ }
28
+ catch (e) {
29
+ if (e instanceof Error) {
30
+ console.warn('[startup] error during adb healthcheck:', e.message);
31
+ }
32
+ else {
33
+ console.warn('[startup] error during adb healthcheck:', String(e));
34
+ }
35
+ }
36
+ })();
18
37
  function wrapResponse(data) {
19
38
  return {
20
39
  content: [{
@@ -2,7 +2,60 @@ import { promises as fsPromises, existsSync } from 'fs';
2
2
  import path from 'path';
3
3
  import { detectJavaHome } from '../java.js';
4
4
  import { execCmd } from '../exec.js';
5
- export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
5
+ import { spawnSync } from 'child_process';
6
+ function findInPath(cmd) {
7
+ try {
8
+ // prefer command -v for POSIX
9
+ const res = spawnSync('command', ['-v', cmd], { encoding: 'utf8' });
10
+ if (res.status === 0 && res.stdout)
11
+ return res.stdout.trim();
12
+ }
13
+ catch (e) {
14
+ console.debug(`[findInPath] command -v ${cmd} failed: ${String(e)}`);
15
+ }
16
+ try {
17
+ const res = spawnSync('which', [cmd], { encoding: 'utf8' });
18
+ if (res.status === 0 && res.stdout)
19
+ return res.stdout.trim();
20
+ }
21
+ catch (e) {
22
+ console.debug(`[findInPath] which ${cmd} failed: ${String(e)}`);
23
+ }
24
+ return null;
25
+ }
26
+ export function resolveAdbCmd() {
27
+ // Priority: explicit env ADB_PATH -> ANDROID_SDK_ROOT/platform-tools/adb -> ANDROID_HOME/platform-tools/adb -> ~/Library/Android/sdk/platform-tools/adb -> PATH discovery -> 'adb'
28
+ if (process.env.ADB_PATH && process.env.ADB_PATH.trim())
29
+ return process.env.ADB_PATH;
30
+ const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME;
31
+ if (sdkRoot) {
32
+ const candidate = path.join(sdkRoot, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
33
+ if (existsSync(candidate))
34
+ return candidate;
35
+ }
36
+ // common macOS user SDK path
37
+ const homeSdk = path.join(process.env.HOME || '', 'Library', 'Android', 'sdk', 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
38
+ if (existsSync(homeSdk))
39
+ return homeSdk;
40
+ const found = findInPath('adb');
41
+ if (found)
42
+ return found;
43
+ return 'adb';
44
+ }
45
+ export function getAdbCmd() { return resolveAdbCmd(); }
46
+ export function ensureAdbAvailable() {
47
+ const adb = resolveAdbCmd();
48
+ try {
49
+ const res = spawnSync(adb, ['--version'], { encoding: 'utf8' });
50
+ if (res.status === 0) {
51
+ return { adbCmd: adb, ok: true, version: (res.stdout || res.stderr || '').trim() };
52
+ }
53
+ return { adbCmd: adb, ok: false, error: (res.stderr || res.stdout || '').trim() };
54
+ }
55
+ catch (err) {
56
+ return { adbCmd: adb, ok: false, error: String(err) };
57
+ }
58
+ }
6
59
  /**
7
60
  * Prepare Gradle execution options for building an Android project.
8
61
  * Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
@@ -24,26 +77,67 @@ export async function prepareGradle(projectPath) {
24
77
  }
25
78
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
26
79
  const env = Object.assign({}, process.env);
80
+ // Ensure child processes can find Android platform-tools (adb, etc.) by
81
+ // prepending the platform-tools directory to PATH for spawned processes.
82
+ const adbPath = resolveAdbCmd();
83
+ let platformToolsDir = undefined;
84
+ try {
85
+ if (adbPath && adbPath !== 'adb' && existsSync(adbPath)) {
86
+ platformToolsDir = path.dirname(adbPath);
87
+ }
88
+ }
89
+ catch (e) {
90
+ console.debug(`[prepareGradle] error resolving adbPath: ${String(e)}`);
91
+ }
92
+ const pathParts = [];
27
93
  if (detectedJavaHome) {
28
94
  if (env.JAVA_HOME !== detectedJavaHome) {
29
95
  env.JAVA_HOME = detectedJavaHome;
30
- env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
31
96
  }
97
+ const javaBin = path.join(detectedJavaHome, 'bin');
98
+ pathParts.push(javaBin);
32
99
  gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
33
100
  gradleArgs.push('--no-daemon');
34
101
  env.GRADLE_JAVA_HOME = detectedJavaHome;
35
102
  }
103
+ if (platformToolsDir) {
104
+ // Prepend platform-tools so gradle and child tools find adb without modifying global env
105
+ if (!env.PATH || !env.PATH.includes(platformToolsDir)) {
106
+ pathParts.push(platformToolsDir);
107
+ }
108
+ }
109
+ else if (process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME) {
110
+ const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || '';
111
+ const candidate = path.join(sdkRoot, 'platform-tools');
112
+ if (existsSync(candidate) && (!env.PATH || !env.PATH.includes(candidate))) {
113
+ pathParts.push(candidate);
114
+ }
115
+ }
116
+ else {
117
+ // also try common user sdk location
118
+ const homeSdkTools = path.join(process.env.HOME || '', 'Library', 'Android', 'sdk', 'platform-tools');
119
+ if (existsSync(homeSdkTools) && (!env.PATH || !env.PATH.includes(homeSdkTools))) {
120
+ pathParts.push(homeSdkTools);
121
+ }
122
+ }
123
+ if (pathParts.length > 0) {
124
+ env.PATH = `${pathParts.join(path.delimiter)}${path.delimiter}${env.PATH || ''}`;
125
+ }
36
126
  try {
37
127
  delete env.SHELL;
38
128
  }
39
- catch { }
129
+ catch (e) {
130
+ console.debug('[prepareGradle] failed to delete SHELL from env:', String(e));
131
+ }
40
132
  const useWrapper = existsSync(gradlewPath);
41
133
  const spawnOpts = { cwd: projectPath, env };
42
134
  if (useWrapper) {
43
135
  try {
44
136
  await fsPromises.chmod(gradlewPath, 0o755);
45
137
  }
46
- catch { }
138
+ catch (e) {
139
+ console.debug('[prepareGradle] chmod failed for gradlew:', String(e));
140
+ }
47
141
  spawnOpts.shell = false;
48
142
  }
49
143
  else {
@@ -117,8 +211,8 @@ export async function getAndroidDeviceMetadata(appId, deviceId) {
117
211
  resolvedDeviceId = deviceLines[0];
118
212
  }
119
213
  }
120
- catch {
121
- // ignore and continue without resolvedDeviceId
214
+ catch (e) {
215
+ console.debug('[getAndroidDeviceMetadata] error detecting single device: ' + String(e));
122
216
  }
123
217
  }
124
218
  // Run these in parallel to avoid sequential timeouts
@@ -130,7 +224,8 @@ export async function getAndroidDeviceMetadata(appId, deviceId) {
130
224
  const simulator = simOutput === '1';
131
225
  return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator };
132
226
  }
133
- catch {
227
+ catch (e) {
228
+ console.debug('[getAndroidDeviceMetadata] failed to gather metadata: ' + String(e));
134
229
  return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
135
230
  }
136
231
  }
@@ -170,19 +265,22 @@ export async function listAndroidDevices(appId) {
170
265
  const pm = await execAdb(['shell', 'pm', 'path', appId], serial);
171
266
  appInstalled = !!(pm && pm.includes('package:'));
172
267
  }
173
- catch {
268
+ catch (e) {
269
+ console.debug(`[listAndroidDevices] pm check failed for ${serial}: ${String(e)}`);
174
270
  appInstalled = false;
175
271
  }
176
272
  }
177
273
  return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled };
178
274
  }
179
- catch {
275
+ catch (e) {
276
+ console.debug(`[listAndroidDevices] failed gathering metadata for ${serial}: ${String(e)}`);
180
277
  return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false };
181
278
  }
182
279
  }));
183
280
  return infos;
184
281
  }
185
- catch {
282
+ catch (e) {
283
+ console.debug('[listAndroidDevices] failed to list devices: ' + String(e));
186
284
  return [];
187
285
  }
188
286
  }
@@ -207,8 +305,8 @@ export async function getScreenResolution(deviceId) {
207
305
  return { width: parseInt(match[1]), height: parseInt(match[2]) };
208
306
  }
209
307
  }
210
- catch {
211
- // ignore
308
+ catch (e) {
309
+ console.debug('[getScreenResolution] failed to detect screen resolution: ' + String(e));
212
310
  }
213
311
  return { width: 0, height: 0 };
214
312
  }
@@ -48,7 +48,7 @@ function startCompanionIfNeeded(companionPath, udid) {
48
48
  return { started: true };
49
49
  }
50
50
  catch (e) {
51
- return { started: false, error: e.message };
51
+ return { started: false, error: e instanceof Error ? e.message : String(e) };
52
52
  }
53
53
  }
54
54
  async function main() {
@@ -1,76 +1,133 @@
1
1
  import { execSync } from 'child_process';
2
2
  import { existsSync } from 'fs';
3
3
  import path from 'path';
4
+ function isJavaVersionAcceptable(output) {
5
+ if (!output)
6
+ return false;
7
+ const s = String(output);
8
+ // Accept Java 17 or 21 (common supported LTS for Android builds)
9
+ if (/\b17\b/.test(s) || /17\./.test(s))
10
+ return true;
11
+ if (/\b21\b/.test(s) || /21\./.test(s))
12
+ return true;
13
+ return false;
14
+ }
15
+ import { spawnSync } from 'child_process';
16
+ function javaVersionOf(javaBin) {
17
+ try {
18
+ const res = spawnSync(javaBin, ['-version'], { encoding: 'utf8' });
19
+ // Java prints version to stderr traditionally
20
+ const out = (res.stdout || '') + (res.stderr || '');
21
+ return out || undefined;
22
+ }
23
+ catch (e) {
24
+ console.debug('[javaVersionOf] java -version failed: ' + String(e));
25
+ return undefined;
26
+ }
27
+ }
4
28
  export async function detectJavaHome() {
5
29
  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');
30
+ // 1) Honor explicit ANDROID_STUDIO_JDK env (highest priority)
31
+ const envStudio = process.env.ANDROID_STUDIO_JDK || process.env.ANDROID_STUDIO_JBR;
32
+ if (envStudio && existsSync(path.join(envStudio, 'bin', 'java'))) {
33
+ const v = javaVersionOf(path.join(envStudio, 'bin', 'java'));
34
+ if (isJavaVersionAcceptable(v)) {
35
+ console.debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio);
36
+ return envStudio;
17
37
  }
38
+ console.debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions');
18
39
  }
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
40
+ // 2) Android Studio JBR candidates (prefer these over JAVA_HOME)
24
41
  const jbrCandidates = [
25
42
  '/Applications/Android Studio.app/Contents/jbr',
43
+ '/Applications/Android Studio.app/Contents/jbr/Contents/Home',
26
44
  '/Applications/Android Studio Preview.app/Contents/jbr',
45
+ '/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home',
27
46
  '/Applications/Android Studio Preview 2022.3.app/Contents/jbr',
28
- '/Applications/Android Studio Preview 2023.1.app/Contents/jbr'
47
+ '/Applications/Android Studio Preview 2022.3.app/Contents/jbr/Contents/Home',
48
+ '/Applications/Android Studio Preview 2023.1.app/Contents/jbr',
49
+ '/Applications/Android Studio Preview 2023.1.app/Contents/jbr/Contents/Home'
29
50
  ];
30
51
  for (const p of jbrCandidates) {
31
52
  const javaBin = path.join(p, 'bin', 'java');
32
53
  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;
54
+ const v = javaVersionOf(javaBin);
55
+ if (isJavaVersionAcceptable(v)) {
56
+ console.debug('[java.detect] Found Android Studio JBR at:', p);
57
+ return p;
58
+ }
59
+ }
60
+ }
61
+ // 3) If JAVA_HOME set, validate it (accept 17 or 21)
62
+ if (process.env.JAVA_HOME) {
63
+ try {
64
+ const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java');
65
+ const v = javaVersionOf(javaBin);
66
+ if (isJavaVersionAcceptable(v)) {
67
+ console.debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME);
68
+ return process.env.JAVA_HOME;
37
69
  }
38
- catch { }
70
+ console.debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search');
71
+ }
72
+ catch {
73
+ console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK');
39
74
  }
40
75
  }
41
- // macOS /usr/libexec/java_home
76
+ // 4) macOS explicit path for JDK 17
77
+ const explicit = '/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home';
78
+ if (existsSync(explicit))
79
+ return explicit;
80
+ // 5) macOS /usr/libexec/java_home try supported versions
81
+ try {
82
+ const out17 = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
83
+ if (out17)
84
+ return out17;
85
+ }
86
+ catch (e) {
87
+ console.debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e));
88
+ }
42
89
  try {
43
- const out = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
44
- if (out)
45
- return out;
90
+ const out21 = execSync('/usr/libexec/java_home -v 21', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
91
+ if (out21)
92
+ return out21;
46
93
  }
47
- catch { }
48
- // macOS common JDK locations
94
+ catch (e) {
95
+ console.debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e));
96
+ }
97
+ // 6) macOS common JDK locations
49
98
  try {
50
99
  const homes = execSync('ls -1 /Library/Java/JavaVirtualMachines || true', { stdio: ['ignore', 'pipe', 'inherit'] }).toString().split(/\r?\n/).filter(Boolean);
51
100
  for (const h of homes) {
52
- if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17')) {
101
+ if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17') || h.toLowerCase().includes('21') || h.toLowerCase().includes('jdk-21')) {
53
102
  const candidate = `/Library/Java/JavaVirtualMachines/${h}/Contents/Home`;
54
103
  return candidate;
55
104
  }
56
105
  }
57
106
  }
58
- catch { }
59
- // Linux locations
107
+ catch (e) {
108
+ console.debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e));
109
+ }
110
+ // 7) Linux locations
60
111
  const linuxCandidates = [
61
112
  '/usr/lib/jvm/java-17-openjdk-amd64',
62
113
  '/usr/lib/jvm/java-17-openjdk',
63
114
  '/usr/lib/jvm/zulu17',
64
- '/usr/lib/jvm/temurin-17-jdk'
115
+ '/usr/lib/jvm/temurin-17-jdk',
116
+ '/usr/lib/jvm/temurin-21-jdk',
117
+ '/usr/lib/jvm/java-21-openjdk-amd64'
65
118
  ];
66
119
  for (const p of linuxCandidates) {
67
120
  try {
68
121
  if (existsSync(p))
69
122
  return p;
70
123
  }
71
- catch { }
124
+ catch (e) {
125
+ console.debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`);
126
+ }
72
127
  }
73
128
  }
74
- catch { }
129
+ catch (e) {
130
+ console.debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e));
131
+ }
75
132
  return undefined;
76
133
  }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.19.1]
6
+
7
+ - Fixed Android install issues
8
+
9
+ ## [0.19.0]
10
+
11
+ - Added `observe_until` interaction tool: waits for UI, log, screen fingerprint or idle conditions with configurable polling and timeout. Returns rich details on match (element info, log line, new fingerprint).
12
+
13
+
5
14
  ## [0.18.0]
6
15
  - Added `find_element` interact tool: semantic UI element search with actionable tap coordinates and lightweight telemetry. The tool searches the UI tree for the best match by text, content description, resource-id, and class, scores candidates (exact, partial, resource-id), and returns the most relevant visible element. When a matching node is non-interactable (e.g., Compose Text child), the tool locates a clickable ancestor (parent or containing element) and returns actionable tapCoordinates (x,y). The handler also returns a `confidence` value and `telemetry` metadata (matchedIndex, matchedInteractable) to aid agent decision-making and logging. Implemented as `ToolsInteract.findElementHandler` and covered by unit tests.
7
16
 
@@ -151,3 +151,55 @@ Notes:
151
151
  - The tool favours actionable (clickable/focusable) targets; when a matching node is not directly actionable, it finds the smallest containing clickable ancestor.
152
152
  - Unit tests for edge cases (parent-clickable child-text, resource-id matches, fuzzy matching) are under `test/observe/unit/find_element.test.ts`.
153
153
 
154
+ ---
155
+
156
+ ## observe_until
157
+
158
+ Purpose:
159
+ - Wait for a condition to occur on the device: UI element appearance, a log line, a screen fingerprint change, or an idle/stable screen state.
160
+
161
+ Supported types and behavior:
162
+ - ui: Delegates to `find_element` to perform a semantic search of the UI tree. Returns the matched element descriptor (including tapCoordinates) when found.
163
+ - log: Reads the active log stream (via `start_log_stream`/`readLogStreamHandler`) and falls back to a snapshot of recent logs (`getLogsHandler`). Matches when the query substring appears in a new log line after a captured baseline.
164
+ - screen: Compares screen fingerprints (visual checks) against an initial baseline and returns when fingerprint changes. If `query` is provided it will attempt a `find_element` on the new screen to validate the expected content.
165
+ - idle: Waits until the screen fingerprint remains stable for a short stability window (default 1000ms).
166
+
167
+ Input (ToolsInteract.observeUntilHandler):
168
+ ```
169
+ { "type": "ui|log|screen|idle", "query": "optional string", "timeoutMs": 5000, "pollIntervalMs": 200, "platform": "android|ios", "deviceId": "optional device id" }
170
+ ```
171
+
172
+ Success response highlights:
173
+ - success: true
174
+ - type: requested type
175
+ - matched: true
176
+ - details: human-friendly explanation
177
+ - timestamp: epoch ms
178
+ - element: (for ui/screen when matched) actionable element metadata with tapCoordinates
179
+ - log: (for log) matched log message and raw entry
180
+ - newFingerprint: (for screen) new fingerprint value
181
+
182
+ Failure/timeout response:
183
+ - success: false
184
+ - error or reason: explanation
185
+ - type: requested type
186
+ - timeoutMs: value used
187
+
188
+ Notes & tips:
189
+ - Defaults (timeoutMs=5000, pollIntervalMs=200) balance responsiveness with device query overhead; adjust in tests or scripts as needed.
190
+ - For UI-sensitive flows prefer type='ui' rather than relying solely on visual fingerprint changes, as some UI updates don't alter the fingerprint.
191
+
192
+ Tests:
193
+ - Unit: `test/interact/unit/observe_until.test.ts`
194
+ - Device runner: `test/interact/device/observe_until_device.ts` (requires devices/emulators and adb/xcrun in PATH)
195
+
196
+ Example:
197
+ ```
198
+ // Wait up to 5s for a button labeled "Generate Session" on Android
199
+ ToolsInteract.observeUntilHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 5000, platform: 'android' })
200
+ ```
201
+
202
+ Troubleshooting:
203
+ - If observe_until(log) never matches, ensure log streaming is started for the target package and baseline logs captured correctly.
204
+ - If observe_until(screen) times out despite visible UI change, try type='ui' to validate content-level changes.
205
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {