mobile-debug-mcp 0.32.1 → 0.32.2

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.
@@ -10,6 +10,7 @@ const devicePort = 39019;
10
10
  const active = new Map();
11
11
  const starting = new Map();
12
12
  const stopping = new Map();
13
+ const bridgeSupport = new Map();
13
14
  export function androidTreeBridgeEnabled() {
14
15
  return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK);
15
16
  }
@@ -168,6 +169,15 @@ export async function readAndroidTreeFromBridge(deviceId) {
168
169
  return null;
169
170
  let bridge;
170
171
  try {
172
+ let supported = bridgeSupport.get(deviceId);
173
+ if (supported === undefined) {
174
+ const sdkLevel = Number(await execAdb(['shell', 'getprop', 'ro.build.version.sdk'], deviceId));
175
+ supported = Number.isInteger(sdkLevel) && sdkLevel >= 34;
176
+ if (supported)
177
+ bridgeSupport.set(deviceId, true);
178
+ }
179
+ if (!supported)
180
+ return null;
171
181
  bridge = await ensure(deviceId, apk);
172
182
  const output = await request(bridge, 'TREE', remainingBudget(10000));
173
183
  if (!output.startsWith('OK\n') || !output.includes('<hierarchy'))
@@ -6,7 +6,7 @@ import { handleToolCall } from './server/tool-handlers.js';
6
6
  export { wrapResponse, toolDefinitions, handleToolCall };
7
7
  export const serverInfo = {
8
8
  name: 'mobile-debug-mcp',
9
- version: '0.32.1'
9
+ version: '0.32.2'
10
10
  };
11
11
  export function createServer() {
12
12
  const server = new Server(serverInfo, {
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.32.2]
6
+ - bug fix
7
+
5
8
  ## [0.32.1]
6
9
  - Further performance enhancements
7
10
 
@@ -49,3 +49,10 @@ network failures; see `comparison.md` for the outcome and limitation.
49
49
  The report commit SHAs are available locally as `codex/spec-019-baseline` and
50
50
  `codex/spec-019-candidate`; create clean, built checkouts of those refs to
51
51
  reproduce the paired measurement.
52
+
53
+ For the opt-in Android bridge, run
54
+ `LATENCY_DEVICE_ID=emulator-5554 node --import tsx test/device/automated/ui-tree-performance/bridge-probe.ts`.
55
+ Its `bridge-probe.json` compares ten bridge and legacy reads, checks a network
56
+ update, and checks a timer that changes visually without emitting an
57
+ accessibility event. The timer check catches a stale accessibility cache even
58
+ when every bridge request calls `getRootInActiveWindow()`.
@@ -3,13 +3,14 @@
3
3
  "device_id": "[redacted]",
4
4
  "fixture": "latency-journey-app",
5
5
  "network_freshness_passed": true,
6
+ "silent_timer_freshness_passed": true,
6
7
  "bridge": {
7
8
  "samples": {
8
9
  "count": 10,
9
- "p50": 6.830375,
10
- "p95": 14.933042,
11
- "min": 5.825959,
12
- "max": 14.933042
10
+ "p50": 3.122458,
11
+ "p95": 4.390417,
12
+ "min": 2.858792,
13
+ "max": 4.390417
13
14
  },
14
15
  "node_count": 7,
15
16
  "identified_nodes": 4
@@ -17,10 +18,10 @@
17
18
  "legacy": {
18
19
  "samples": {
19
20
  "count": 10,
20
- "p50": 2115.17675,
21
- "p95": 2611.298541,
22
- "min": 2042.091,
23
- "max": 2611.298541
21
+ "p50": 2101.953208,
22
+ "p95": 2257.736083,
23
+ "min": 2021.97375,
24
+ "max": 2257.736083
24
25
  },
25
26
  "node_count": 7,
26
27
  "identified_nodes": 4
@@ -92,7 +92,8 @@ Notes:
92
92
  - Elements may also include platform-native identity hints such as `stable_id`, `role`, `test_tag`, `selector`, and `semantic`.
93
93
  - The tree response may include `snapshot_revision`, `captured_at_ms`, and `loading_state` when a reliable signal is available.
94
94
  - Prefer `wait_for_ui` for deterministic element resolution in interactive flows.
95
- - Android can use the experimental persistent hierarchy reader by setting `MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK` to the absolute path of a locally built bridge APK before starting the server. Build it with `npx tsx test/fixtures/ui-tree-bridge/build.ts`. The server installs the helper on first use, reads a fresh accessibility root for every request, and falls back to `uiautomator dump` after stopping the helper if the bridge fails. The helper holds Android's UiAutomation connection while the server runs, so other `uiautomator dump` clients may fail during that time. Leave the variable unset for the default reader. To reproduce the device probe, set `LATENCY_DEVICE_ID` and run `npx tsx test/device/automated/ui-tree-performance/bridge-probe.ts`.
95
+ - Android 14+ can use the experimental persistent hierarchy reader. Build its APK with `npx tsx test/fixtures/ui-tree-bridge/build.ts`, then set `MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK` to its absolute path before starting the server. The reader clears Android's accessibility cache before every tree read, including when a screen changes without an accessibility event. Older Android versions use `uiautomator dump`.
96
+ - If the bridge fails, the server stops it before falling back to `uiautomator dump`. While running, the bridge holds Android's UiAutomation connection, so other `uiautomator dump` clients may fail. Leave the variable unset for the default reader. To reproduce the device probe, set `LATENCY_DEVICE_ID` and run `npx tsx test/device/automated/ui-tree-performance/bridge-probe.ts`.
96
97
 
97
98
  ---
98
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.32.1",
3
+ "version": "0.32.2",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,7 @@ interface Bridge {
20
20
  const active = new Map<string, Bridge>()
21
21
  const starting = new Map<string, Promise<Bridge>>()
22
22
  const stopping = new Map<string, Promise<void>>()
23
+ const bridgeSupport = new Map<string, boolean>()
23
24
 
24
25
  export function androidTreeBridgeEnabled(): boolean {
25
26
  return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK)
@@ -150,6 +151,13 @@ export async function readAndroidTreeFromBridge(deviceId: string): Promise<strin
150
151
  if (!apk) return null
151
152
  let bridge: Bridge | undefined
152
153
  try {
154
+ let supported = bridgeSupport.get(deviceId)
155
+ if (supported === undefined) {
156
+ const sdkLevel = Number(await execAdb(['shell', 'getprop', 'ro.build.version.sdk'], deviceId))
157
+ supported = Number.isInteger(sdkLevel) && sdkLevel >= 34
158
+ if (supported) bridgeSupport.set(deviceId, true)
159
+ }
160
+ if (!supported) return null
153
161
  bridge = await ensure(deviceId, apk)
154
162
  const output = await request(bridge, 'TREE', remainingBudget(10000))
155
163
  if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) throw new Error('Android UI tree bridge returned invalid hierarchy output')
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
16
16
 
17
17
  export const serverInfo = {
18
18
  name: 'mobile-debug-mcp',
19
- version: '0.32.1'
19
+ version: '0.32.2'
20
20
  }
21
21
 
22
22
  export function createServer() {
@@ -111,6 +111,19 @@ async function main() {
111
111
  await readyBridge()
112
112
  bridgeStarted = true
113
113
  adb('shell', 'am', 'force-stop', fixturePackage)
114
+ adb('shell', 'am', 'start', '-n', `${fixturePackage}/.MainActivity`, '--ez', 'silentTimer', 'true')
115
+ const initialDeadline = Date.now() + 5000
116
+ let initialTimer = false
117
+ while (Date.now() < initialDeadline) {
118
+ initialTimer = nodes((await bridgeRead()).xml).some((node) => node['@_content-desc'] === 'silent timer 0:00')
119
+ if (initialTimer) break
120
+ await sleep(50)
121
+ }
122
+ if (!initialTimer) throw new Error('Bridge did not observe the initial silent timer')
123
+ await sleep(2300)
124
+ const updatedTimer = nodes((await bridgeRead()).xml).some((node) => node['@_content-desc'] === 'silent timer 0:01')
125
+ if (!updatedTimer) throw new Error('Bridge reused a cached timer node after a visible update without an accessibility event')
126
+ adb('shell', 'am', 'force-stop', fixturePackage)
114
127
  adb('shell', 'pm', 'clear', fixturePackage)
115
128
  adb('shell', 'am', 'start', '-n', `${fixturePackage}/.MainActivity`, '--es', 'networkBaseUrl', `http://10.0.2.2:${address.port}`, '--es', 'networkRunId', runId)
116
129
  await Promise.race([requestPromise, sleep(10000).then(() => { throw new Error('Fixture request timed out') })])
@@ -164,6 +177,7 @@ async function main() {
164
177
  const report = {
165
178
  platform: 'android', device_id: '[redacted]', fixture: 'latency-journey-app',
166
179
  network_freshness_passed: true,
180
+ silent_timer_freshness_passed: true,
167
181
  bridge: { samples: summarize(bridgeSamples.map((sample) => sample.elapsed_ms)), node_count: bridgeNodes.length, identified_nodes: countIdentifiers(bridgeNodes) },
168
182
  legacy: { samples: summarize(legacySamples.map((sample) => sample.elapsed_ms)), node_count: legacyNodes.length, identified_nodes: countIdentifiers(legacyNodes) }
169
183
  }
@@ -2,7 +2,12 @@ package dev.mobiledebugmcp.latencyfixture;
2
2
 
3
3
  import android.app.Activity;
4
4
  import android.content.Intent;
5
+ import android.graphics.Canvas;
6
+ import android.graphics.Color;
7
+ import android.graphics.Paint;
5
8
  import android.os.Bundle;
9
+ import android.view.View;
10
+ import android.view.accessibility.AccessibilityNodeInfo;
6
11
  import android.widget.Button;
7
12
  import android.widget.LinearLayout;
8
13
  import android.widget.TextView;
@@ -30,9 +35,35 @@ public final class MainActivity extends Activity {
30
35
  super.onCreate(state);
31
36
  String networkBaseUrl = getIntent().getStringExtra("networkBaseUrl");
32
37
  String networkRunId = getIntent().getStringExtra("networkRunId");
33
- if (networkBaseUrl != null && networkRunId != null) networkScreen(networkBaseUrl, networkRunId);
38
+ if (getIntent().getBooleanExtra("silentTimer", false)) silentTimerScreen();
39
+ else if (networkBaseUrl != null && networkRunId != null) networkScreen(networkBaseUrl, networkRunId);
34
40
  else home();
35
41
  }
42
+ private void silentTimerScreen() {
43
+ layout = new LinearLayout(this);
44
+ layout.setPadding(32, 100, 32, 32);
45
+ setContentView(layout);
46
+ SilentTimerView timer = new SilentTimerView();
47
+ layout.addView(timer, new LinearLayout.LayoutParams(-1, 200));
48
+ timer.postDelayed(() -> timer.setValue("0:01"), 2000);
49
+ }
50
+ private final class SilentTimerView extends View {
51
+ private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
52
+ private String value = "0:00";
53
+ SilentTimerView() {
54
+ super(MainActivity.this);
55
+ setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
56
+ paint.setColor(Color.BLACK);
57
+ paint.setTextSize(72);
58
+ }
59
+ // Deliberately redraw without an accessibility event to expose stale node caches.
60
+ void setValue(String next) { value = next; invalidate(); }
61
+ @Override protected void onDraw(Canvas canvas) { canvas.drawText(value, 20, 110, paint); }
62
+ @Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
63
+ super.onInitializeAccessibilityNodeInfo(info);
64
+ info.setContentDescription("silent timer " + value);
65
+ }
66
+ }
36
67
  private void networkScreen(String baseUrl, String runId) {
37
68
  layout = new LinearLayout(this);
38
69
  layout.setOrientation(LinearLayout.VERTICAL);
@@ -4,6 +4,7 @@ import android.app.Instrumentation;
4
4
  import android.app.UiAutomation;
5
5
  import android.graphics.Rect;
6
6
  import android.os.Bundle;
7
+ import android.os.Build;
7
8
  import android.util.Xml;
8
9
  import android.util.Log;
9
10
  import android.view.accessibility.AccessibilityNodeInfo;
@@ -47,13 +48,19 @@ public final class TreeInstrumentation extends Instrumentation {
47
48
  if (!"TREE".equals(command)) {
48
49
  output.write("ERROR invalid_command\n");
49
50
  } else {
50
- AccessibilityNodeInfo root = automation.getRootInActiveWindow();
51
- if (root == null) {
52
- output.write("ERROR null_root\n");
51
+ if (Build.VERSION.SDK_INT < 34) {
52
+ output.write("ERROR cache_clear_unavailable\n");
53
+ } else if (!automation.clearCache()) {
54
+ output.write("ERROR cache_clear_failed\n");
53
55
  } else {
54
- output.write("OK\n");
55
- output.write(serialize(root));
56
- root.recycle();
56
+ AccessibilityNodeInfo root = automation.getRootInActiveWindow();
57
+ if (root == null) {
58
+ output.write("ERROR null_root\n");
59
+ } else {
60
+ output.write("OK\n");
61
+ output.write(serialize(root));
62
+ root.recycle();
63
+ }
57
64
  }
58
65
  }
59
66
  output.flush();
@@ -11,7 +11,8 @@ async function run() {
11
11
  const adbPath = path.join(directory, 'adb')
12
12
  const apkPath = path.join(directory, 'bridge.apk')
13
13
  const launchesPath = path.join(directory, 'launches')
14
- const previous = new Map(['ADB_PATH', 'MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK', 'MCP_FAKE_BRIDGE_PORT', 'MCP_FAKE_BRIDGE_LAUNCHES', 'MCP_FAKE_FORCE_STOP_FAIL'].map((key) => [key, process.env[key]]))
14
+ const getpropsPath = path.join(directory, 'getprops')
15
+ const previous = new Map(['ADB_PATH', 'MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK', 'MCP_FAKE_BRIDGE_PORT', 'MCP_FAKE_BRIDGE_LAUNCHES', 'MCP_FAKE_BRIDGE_GETPROPS', 'MCP_FAKE_FORCE_STOP_FAIL', 'MCP_FAKE_SDK'].map((key) => [key, process.env[key]]))
15
16
  let invalidTree = false
16
17
  const sockets = new Set<Socket>()
17
18
  const server = createServer((socket) => {
@@ -26,6 +27,7 @@ async function run() {
26
27
  })
27
28
  writeFileSync(apkPath, '')
28
29
  writeFileSync(launchesPath, '')
30
+ writeFileSync(getpropsPath, '')
29
31
  writeFileSync(adbPath, `#!/usr/bin/env node
30
32
  const fs = require('node:fs')
31
33
  const args = process.argv.slice(2).join(' ')
@@ -36,6 +38,9 @@ if (args.includes('am instrument')) {
36
38
  process.exit(1)
37
39
  } else if (args.includes('forward tcp:0')) {
38
40
  console.log(process.env.MCP_FAKE_BRIDGE_PORT)
41
+ } else if (args.includes('getprop ro.build.version.sdk')) {
42
+ fs.appendFileSync(process.env.MCP_FAKE_BRIDGE_GETPROPS, 'getprop\\n')
43
+ console.log(process.env.MCP_FAKE_SDK || '36')
39
44
  }
40
45
  `)
41
46
  chmodSync(adbPath, 0o755)
@@ -46,6 +51,7 @@ if (args.includes('am instrument')) {
46
51
  process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK = apkPath
47
52
  process.env.MCP_FAKE_BRIDGE_PORT = String(address.port)
48
53
  process.env.MCP_FAKE_BRIDGE_LAUNCHES = launchesPath
54
+ process.env.MCP_FAKE_BRIDGE_GETPROPS = getpropsPath
49
55
  try {
50
56
  assert.match(await readAndroidTreeFromBridge('test-device') ?? '', /<hierarchy>/)
51
57
  invalidTree = true
@@ -62,6 +68,19 @@ if (args.includes('am instrument')) {
62
68
  assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 2)
63
69
  invalidTree = true
64
70
  assert.equal(await readAndroidTreeFromBridge('test-device'), null, 'clean shutdown permits legacy fallback')
71
+ process.env.MCP_FAKE_SDK = '33'
72
+ assert.equal(await readAndroidTreeFromBridge('old-device'), null, 'older Android versions use the legacy reader')
73
+ assert.equal(await readAndroidTreeFromBridge('old-device'), null)
74
+ assert.equal(readFileSync(getpropsPath, 'utf8').trim().split('\n').length, 3, 'unsupported devices recheck SDK support')
75
+ process.env.MCP_FAKE_SDK = '36'
76
+ invalidTree = false
77
+ assert.match(await readAndroidTreeFromBridge('old-device') ?? '', /<hierarchy>/, 'a newly supported device can start the bridge')
78
+ for (let attempt = 0; attempt < 20 && readFileSync(launchesPath, 'utf8').trim().split('\n').length < 3; attempt++) {
79
+ await new Promise((resolve) => setTimeout(resolve, 50))
80
+ }
81
+ assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 3)
82
+ invalidTree = true
83
+ assert.equal(await readAndroidTreeFromBridge('old-device'), null, 'the new bridge shuts down before fallback')
65
84
  } finally {
66
85
  for (const [key, value] of previous) {
67
86
  if (value === undefined) delete process.env[key]