mobile-debug-mcp 0.20.0 → 0.21.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.
@@ -0,0 +1,85 @@
1
+ import { ToolsObserve } from '../observe/index.js'
2
+ import { ToolsInteract } from '../interact/index.js'
3
+
4
+ async function main() {
5
+ const bundle = 'com.ideamechanics.modul8'
6
+ const sessionId = 'press-ui-test'
7
+ console.log('Starting log stream for', bundle)
8
+ try {
9
+ const platform = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
10
+ const pkg = bundle
11
+ const sessionOpts = { platform, packageName: pkg, sessionId }
12
+ const start = await ToolsObserve.startLogStreamHandler(sessionOpts)
13
+ console.log('startLogStream result:', JSON.stringify(start))
14
+ } catch (e) {
15
+ console.error('startLogStream failed:', e instanceof Error ? e.message : String(e))
16
+ }
17
+
18
+ console.log('\nAttempting to find and tap Generate Session...')
19
+ let el = null
20
+ try {
21
+ const platform = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
22
+ const found = await ToolsInteract.findElementHandler({ query: 'Generate Session', exact: platform === 'android', timeoutMs: 3000, platform })
23
+ const foundFlag = found && typeof (found).found !== 'undefined' ? found.found : false
24
+ console.log('findElementHandler:', foundFlag ? 'found' : 'not found')
25
+ if (foundFlag) {
26
+ el = found.element
27
+ console.log('Matched element:', JSON.stringify(el))
28
+ }
29
+ } catch (e) { console.error('findElementHandler err:', e) }
30
+
31
+ try {
32
+ const platform = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
33
+ const device = process.argv.includes('--device') ? process.argv[process.argv.indexOf('--device')+1] : undefined
34
+
35
+ // Strict foreground check: ensure the package is in foreground before tapping
36
+ try {
37
+ const fg = await ToolsInteract.get_current_activity ? await ToolsInteract.get_current_activity({ platform, deviceId: device }) : null
38
+ console.log('Foreground activity info:', fg)
39
+ if (fg && fg.activity && !String(fg.activity).includes('modul8')) {
40
+ console.error('App is not foreground. Aborting tap.')
41
+ process.exit(2)
42
+ }
43
+ } catch (e) { console.warn('Foreground check failed:', e) }
44
+
45
+ if (el && el.tapCoordinates) {
46
+ console.log('Tapping matched element at', JSON.stringify(el.tapCoordinates))
47
+ await ToolsInteract.tapHandler({ platform, x: el.tapCoordinates.x, y: el.tapCoordinates.y, deviceId: device })
48
+ } else {
49
+ console.log('Element not found, tapping center fallback')
50
+ await ToolsInteract.tapHandler({ platform, x: 200, y: 400, deviceId: device })
51
+ }
52
+ } catch (e) { console.error('Tap failed:', e) }
53
+
54
+ console.log('\nNow waiting for "Generating session" to disappear (timeout 30s)')
55
+ const start = Date.now()
56
+ const deadline = start + (parseInt(process.argv.includes('--timeout') ? process.argv[process.argv.indexOf('--timeout')+1] : '30000'))
57
+ let poll = 0
58
+ let disappeared = false
59
+ const platformArg = process.argv.includes('--platform') ? process.argv[process.argv.indexOf('--platform')+1] : 'android'
60
+ const deviceArg = process.argv.includes('--device') ? process.argv[process.argv.indexOf('--device')+1] : undefined
61
+ while (Date.now() <= deadline) {
62
+ poll++
63
+ try {
64
+ const f = await ToolsInteract.findElementHandler({ query: 'Generating session', exact: false, timeoutMs: 1000, platform: platformArg, deviceId: deviceArg })
65
+ const exists = f && typeof f.found !== 'undefined' ? f.found : false
66
+ console.log(`poll ${poll}: generating exists=${exists}`)
67
+ if (!exists) { disappeared = true; break }
68
+ } catch (e) { console.error('findElement err:', e) }
69
+ await new Promise(r => setTimeout(r, 500))
70
+ }
71
+
72
+ const elapsed = Date.now()-start
73
+ console.log('Result: disappeared=', disappeared, 'polls=', poll, 'elapsedMs=', elapsed)
74
+
75
+ // Platform-specific post-conditions
76
+
77
+
78
+ console.log('\nFinal Result: success=', disappeared)
79
+
80
+ console.log('\nStopping log stream')
81
+ try { const stop = await ToolsObserve.stopLogStreamHandler({ platform: 'ios', sessionId }); console.log('stopLogStream', stop) } catch (e) { console.error('stop failed', e) }
82
+ process.exit(disappeared ? 0 : 2)
83
+ }
84
+
85
+ main().catch(e=>{ console.error(e); process.exit(1) })
@@ -0,0 +1,123 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { ToolsObserve } from '../observe/index.js'
4
+ import { ToolsInteract } from '../interact/index.js'
5
+
6
+ const argv = process.argv.slice(2)
7
+ const opts = { platform: 'android', deviceId: undefined, timeoutMs: 180000, stabilityMs: 1000, pollIntervalMs: 500, saveDir: '/tmp' }
8
+ for (let i = 0; i < argv.length; i++) {
9
+ if (argv[i] === '--platform') opts.platform = argv[++i]
10
+ else if (argv[i] === '--deviceId') opts.deviceId = argv[++i]
11
+ else if (argv[i] === '--timeout') opts.timeoutMs = Number(argv[++i])
12
+ else if (argv[i] === '--stabilityMs') opts.stabilityMs = Number(argv[++i])
13
+ else if (argv[i] === '--poll') opts.pollIntervalMs = Number(argv[++i])
14
+ else if (argv[i] === '--saveDir') opts.saveDir = argv[++i]
15
+ }
16
+
17
+ const platform = opts.platform
18
+ const deviceId = opts.deviceId
19
+ const timeoutMs = opts.timeoutMs
20
+ const stabilityMs = opts.stabilityMs
21
+ const poll = opts.pollIntervalMs
22
+ const saveDir = opts.saveDir
23
+
24
+ const ts = () => Date.now()
25
+
26
+ async function findAndTapGenerate() {
27
+ try {
28
+ const found = await ToolsInteract.findElementHandler({ query: 'Generate Session', exact: false, timeoutMs: 3000, platform, deviceId })
29
+ const foundFlag = found && typeof found.found !== 'undefined' ? found.found : false
30
+ console.log('find Generate Session:', foundFlag)
31
+ if (foundFlag) {
32
+ const el = found.element
33
+ if (el && el.tapCoordinates) {
34
+ console.log('Tapping at', JSON.stringify(el.tapCoordinates))
35
+ await ToolsInteract.tapHandler({ platform, x: el.tapCoordinates.x, y: el.tapCoordinates.y, deviceId })
36
+ return true
37
+ } else {
38
+ // tap center of parent bounds if available
39
+ if (el && el.bounds) {
40
+ const b = el.bounds
41
+ const cx = Math.floor((b.left + b.right)/2)
42
+ const cy = Math.floor((b.top + b.bottom)/2)
43
+ console.log('Tapping parent center', cx, cy)
44
+ await ToolsInteract.tapHandler({ platform, x: cx, y: cy, deviceId })
45
+ return true
46
+ }
47
+ }
48
+ }
49
+ } catch (e) { console.error('error finding/tapping generate:', e) }
50
+ return false
51
+ }
52
+
53
+ async function captureUITree(savePrefix) {
54
+ try {
55
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId })
56
+ const p = path.join(saveDir, `${savePrefix}_ui_${Date.now()}.json`)
57
+ fs.writeFileSync(p, JSON.stringify(tree, null, 2))
58
+ console.log('Wrote UI tree', p)
59
+ return tree
60
+ } catch (e) { console.error('failed getUITree:', e); return null }
61
+ }
62
+
63
+ async function tryCaptureScreenshot(savePrefix) {
64
+ try {
65
+ const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
66
+ // handler may return { image: 'data:image/png;base64,...' } or blocks; try to extract base64
67
+ let b64 = null
68
+ if (res && res.image && typeof res.image === 'string') b64 = res.image.replace(/^data:image\/png;base64,/, '')
69
+ else if (Array.isArray(res) && res.length) {
70
+ for (const blk of res) { if (blk && blk.mime === 'image/png' && blk.data) { b64 = blk.data; break } }
71
+ } else if (res && res.blocks) {
72
+ for (const blk of res.blocks) { if (blk && blk.mime === 'image/png' && blk.data) { b64 = blk.data; break } }
73
+ }
74
+ if (b64) {
75
+ const p = path.join(saveDir, `${savePrefix}_shot_${Date.now()}.png`)
76
+ fs.writeFileSync(p, Buffer.from(b64, 'base64'))
77
+ console.log('Wrote screenshot', p)
78
+ return p
79
+ }
80
+ console.log('No image returned from captureScreenshotHandler')
81
+ } catch (e) { console.error('screenshot error:', e) }
82
+ return null
83
+ }
84
+
85
+ async function main() {
86
+ console.log('Starting test: tap Generate Session and wait for Play session to appear stably')
87
+ const tapped = await findAndTapGenerate()
88
+ if (!tapped) console.log('Warning: generate tap not performed')
89
+
90
+ const start = ts()
91
+ const deadline = start + timeoutMs
92
+ let matchedAt = null
93
+ let pollCount = 0
94
+
95
+ while (Date.now() <= deadline) {
96
+ pollCount++
97
+ const savePrefix = `gen_wait_p${pollCount}`
98
+ const tree = await captureUITree(savePrefix)
99
+ try {
100
+ const f = await ToolsInteract.findElementHandler({ query: 'Play session', exact: false, timeoutMs: 500, platform, deviceId })
101
+ const exists = f && typeof f.found !== 'undefined' ? f.found : false
102
+ console.log(`poll ${pollCount}: play exists=${exists}`)
103
+ await tryCaptureScreenshot(savePrefix)
104
+ if (exists) {
105
+ if (matchedAt === null) matchedAt = Date.now()
106
+ const stable = Date.now() - matchedAt
107
+ if (stable >= stabilityMs) {
108
+ console.log('SUCCESS: Play session present and stable for', stable, 'ms after', Date.now()-start, 'ms and', pollCount, 'polls')
109
+ process.exit(0)
110
+ }
111
+ } else {
112
+ matchedAt = null
113
+ }
114
+ } catch (e) { console.error('error during poll:', e) }
115
+
116
+ await new Promise(r => setTimeout(r, poll))
117
+ }
118
+
119
+ console.error('FAIL: timeout waiting for Play session (polls=', pollCount, ')')
120
+ process.exit(2)
121
+ }
122
+
123
+ main().catch(e=>{ console.error('fatal', e); process.exit(1) })
package/dist/server.js CHANGED
@@ -314,6 +314,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
314
314
  required: ["previousFingerprint"]
315
315
  }
316
316
  },
317
+ {
318
+ name: "observe_until",
319
+ description: "Wait for a UI condition (element present/absent) and require a stability window before returning success. Network-based waiting is not required; UI-only synchronization is the default and primary mode.",
320
+ inputSchema: {
321
+ type: "object",
322
+ properties: {
323
+ type: { type: "string", enum: ["ui", "log", "screen", "idle"], description: "Condition type to observe", default: "ui" },
324
+ query: { type: "string", description: "Optional query string for ui/log/screen types" },
325
+ timeoutMs: { type: "number", description: "Timeout in ms to wait for condition (default 30000)", default: 30000 },
326
+ pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300, clamped to 250-500)", default: 300 },
327
+ match: { type: "string", enum: ["present", "absent"], description: "Match mode for UI checks: 'present' or 'absent' (default 'present')", default: "present" },
328
+ stability_ms: { type: "number", description: "Stability window in ms that the condition must hold before returning success (default 700)", default: 700 },
329
+ includeSnapshotOnFailure: { type: "boolean", description: "Whether to include a debug snapshot on timeout (default true)", default: true },
330
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override" },
331
+ deviceId: { type: "string", description: "Optional device serial/udid" }
332
+ }
333
+ }
334
+ },
317
335
  {
318
336
  name: "wait_for_element",
319
337
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -0,0 +1,101 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ function readPropertiesFile(p) {
5
+ try {
6
+ const txt = readFileSync(p, { encoding: 'utf8' });
7
+ const lines = String(txt).split(/\r?\n/);
8
+ const out = {};
9
+ for (const l of lines) {
10
+ const trimmed = l.trim();
11
+ if (!trimmed || trimmed.startsWith('#'))
12
+ continue;
13
+ const idx = trimmed.indexOf('=');
14
+ if (idx === -1)
15
+ continue;
16
+ const k = trimmed.substring(0, idx).trim();
17
+ const v = trimmed.substring(idx + 1).trim();
18
+ out[k] = v;
19
+ }
20
+ return out;
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function javaBinExists(p) {
27
+ if (!p)
28
+ return false;
29
+ try {
30
+ const javaPath = path.join(p, 'bin', 'java');
31
+ if (existsSync(javaPath))
32
+ return true;
33
+ const alt = path.join(p, 'Contents', 'Home', 'bin', 'java');
34
+ if (existsSync(alt))
35
+ return true;
36
+ return false;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ export async function checkGradle() {
43
+ const issues = [];
44
+ const filesChecked = [];
45
+ const suggestedFixes = [];
46
+ let gradleJavaHome;
47
+ // 1) explicit env
48
+ if (process.env.GRADLE_JAVA_HOME) {
49
+ gradleJavaHome = process.env.GRADLE_JAVA_HOME;
50
+ if (!javaBinExists(gradleJavaHome)) {
51
+ issues.push(`GRADLE_JAVA_HOME is set to '${gradleJavaHome}' but no java binary was found there`);
52
+ suggestedFixes.push('Unset GRADLE_JAVA_HOME or point it to a valid JDK (e.g., /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home)');
53
+ }
54
+ }
55
+ // 2) user gradle.properties
56
+ const gradleUserHome = process.env.GRADLE_USER_HOME || path.join(os.homedir(), '.gradle');
57
+ const userProps = path.join(gradleUserHome, 'gradle.properties');
58
+ filesChecked.push(userProps);
59
+ try {
60
+ const props = readPropertiesFile(userProps);
61
+ if (props['org.gradle.java.home']) {
62
+ const p = props['org.gradle.java.home'];
63
+ gradleJavaHome = gradleJavaHome || p;
64
+ if (!javaBinExists(p)) {
65
+ issues.push(`org.gradle.java.home in ${userProps} points to '${p}' which does not look like a valid JDK`);
66
+ suggestedFixes.push(`Edit ${userProps} to remove or correct org.gradle.java.home`);
67
+ }
68
+ }
69
+ }
70
+ catch { }
71
+ // 3) system gradle.properties
72
+ const systemProps = '/etc/gradle/gradle.properties';
73
+ filesChecked.push(systemProps);
74
+ try {
75
+ const props = readPropertiesFile(systemProps);
76
+ if (props['org.gradle.java.home']) {
77
+ const p = props['org.gradle.java.home'];
78
+ gradleJavaHome = gradleJavaHome || p;
79
+ if (!javaBinExists(p)) {
80
+ issues.push(`org.gradle.java.home in ${systemProps} points to '${p}' which does not look like a valid JDK`);
81
+ suggestedFixes.push(`Edit ${systemProps} to remove or correct org.gradle.java.home`);
82
+ }
83
+ }
84
+ }
85
+ catch { }
86
+ // 4) GRADLE_HOME fallback
87
+ if (!gradleJavaHome && process.env.GRADLE_HOME) {
88
+ filesChecked.push(process.env.GRADLE_HOME);
89
+ if (javaBinExists(process.env.GRADLE_HOME)) {
90
+ gradleJavaHome = process.env.GRADLE_HOME;
91
+ }
92
+ }
93
+ const gradleValid = !!gradleJavaHome && javaBinExists(gradleJavaHome);
94
+ if (!gradleJavaHome) {
95
+ // no explicit gradle java home detected — not an issue
96
+ }
97
+ else if (!gradleValid) {
98
+ issues.push(`Detected org.gradle.java.home = '${gradleJavaHome}' but it is invalid`);
99
+ }
100
+ return { gradleJavaHome, gradleValid, filesChecked, issues, suggestedFixes };
101
+ }
@@ -1,10 +1,12 @@
1
1
  import { checkAndroid } from './android.js';
2
2
  import { checkIOS } from './ios.js';
3
+ import { checkGradle } from './gradle.js';
3
4
  export async function getSystemStatus() {
4
5
  try {
5
6
  const android = await checkAndroid();
6
7
  const ios = await checkIOS();
7
- const issues = [...android.issues, ...ios.issues];
8
+ const gradle = await checkGradle();
9
+ const issues = [...android.issues, ...ios.issues, ...(gradle.issues || [])];
8
10
  const success = issues.length === 0;
9
11
  return {
10
12
  success,
@@ -17,7 +19,11 @@ export async function getSystemStatus() {
17
19
  issues,
18
20
  appInstalled: android.appInstalled,
19
21
  iosAvailable: ios.iosAvailable,
20
- iosDevices: ios.iosDevices
22
+ iosDevices: ios.iosDevices,
23
+ gradleJavaHome: gradle.gradleJavaHome,
24
+ gradleValid: gradle.gradleValid,
25
+ gradleFilesChecked: gradle.filesChecked,
26
+ gradleSuggestedFixes: gradle.suggestedFixes
21
27
  };
22
28
  }
23
29
  catch (e) {
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { detectJavaHome } from '../java.js';
4
4
  import { execCmd } from '../exec.js';
5
5
  import { spawnSync } from 'child_process';
6
+ import { checkGradle } from '../../system/gradle.js';
6
7
  function findInPath(cmd) {
7
8
  try {
8
9
  // prefer command -v for POSIX
@@ -76,6 +77,14 @@ export async function prepareGradle(projectPath) {
76
77
  gradleArgs.push('-Dorg.gradle.caching=false');
77
78
  }
78
79
  const detectedJavaHome = await detectJavaHome().catch(() => undefined);
80
+ // Check for problematic org.gradle.java.home entries (env or properties) and avoid passing invalid values to Gradle
81
+ let gradleCheck;
82
+ try {
83
+ gradleCheck = await checkGradle();
84
+ }
85
+ catch {
86
+ gradleCheck = { gradleJavaHome: undefined, gradleValid: false, filesChecked: [], issues: [] };
87
+ }
79
88
  const env = Object.assign({}, process.env);
80
89
  // Ensure child processes can find Android platform-tools (adb, etc.) by
81
90
  // prepending the platform-tools directory to PATH for spawned processes.
@@ -90,6 +99,7 @@ export async function prepareGradle(projectPath) {
90
99
  console.debug(`[prepareGradle] error resolving adbPath: ${String(e)}`);
91
100
  }
92
101
  const pathParts = [];
102
+ // Prefer a detected (validated) Java home from the system/IDE
93
103
  if (detectedJavaHome) {
94
104
  if (env.JAVA_HOME !== detectedJavaHome) {
95
105
  env.JAVA_HOME = detectedJavaHome;
@@ -100,6 +110,26 @@ export async function prepareGradle(projectPath) {
100
110
  gradleArgs.push('--no-daemon');
101
111
  env.GRADLE_JAVA_HOME = detectedJavaHome;
102
112
  }
113
+ else if (gradleCheck && gradleCheck.gradleJavaHome) {
114
+ // There's an org.gradle.java.home configured somewhere (env or gradle.properties)
115
+ if (gradleCheck.gradleValid) {
116
+ const p = gradleCheck.gradleJavaHome;
117
+ const javaBin = path.join(p, 'bin');
118
+ if (!env.PATH || !env.PATH.includes(javaBin))
119
+ pathParts.push(javaBin);
120
+ gradleArgs.push(`-Dorg.gradle.java.home=${p}`);
121
+ gradleArgs.push('--no-daemon');
122
+ env.GRADLE_JAVA_HOME = p;
123
+ }
124
+ else {
125
+ // Invalid gradle java home detected: avoid passing it to Gradle and remove from spawn env
126
+ console.debug(`[prepareGradle] Invalid org.gradle.java.home detected (${gradleCheck.gradleJavaHome}); removing from spawn env to avoid Gradle error.`);
127
+ try {
128
+ delete env.GRADLE_JAVA_HOME;
129
+ }
130
+ catch { }
131
+ }
132
+ }
103
133
  if (platformToolsDir) {
104
134
  // Prepend platform-tools so gradle and child tools find adb without modifying global env
105
135
  if (!env.PATH || !env.PATH.includes(platformToolsDir)) {
@@ -138,10 +168,12 @@ export async function prepareGradle(projectPath) {
138
168
  catch (e) {
139
169
  console.debug('[prepareGradle] chmod failed for gradlew:', String(e));
140
170
  }
171
+ // Execute the wrapper directly without a shell to avoid shell tokenization of args (spaces in paths)
141
172
  spawnOpts.shell = false;
142
173
  }
143
174
  else {
144
- spawnOpts.shell = true;
175
+ // Prefer executing gradle directly without invoking a shell to preserve argument boundaries
176
+ spawnOpts.shell = false;
145
177
  }
146
178
  return { execCmd, gradleArgs, spawnOpts };
147
179
  }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.21.0]
6
+ - Added `observe_until` as a tool for agents to wait for things like API requests
7
+
8
+ ## [0.20.1]
9
+ - Fixes gradle home issue for android
10
+
5
11
  ## [0.20.0]
6
12
  - Added `get_system_status` tool and refactored system health checks into `src/system`.
7
13
  - Provides a fast environment healthcheck (ADB availability/version, connected devices, log access, Android env vars, and basic iOS xcrun/simulator checks).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.20.0",
3
+ "version": "0.21.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": {