mobile-debug-mcp 0.32.0 → 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.
Files changed (61) hide show
  1. package/README.md +1 -0
  2. package/dist/observe/android-tree-bridge.js +202 -0
  3. package/dist/observe/android.js +49 -15
  4. package/dist/observe/ios.js +45 -29
  5. package/dist/observe/performance-metrics.js +124 -0
  6. package/dist/server/tool-handlers.js +31 -9
  7. package/dist/server-core.js +1 -1
  8. package/dist/utils/exec.js +15 -4
  9. package/docs/CHANGELOG.md +6 -0
  10. package/docs/ROADMAP.md +9 -0
  11. package/docs/benchmarks/ui-tree-performance/README.md +58 -0
  12. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  13. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +29 -0
  14. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  15. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  16. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  17. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  18. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  19. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  20. package/docs/tools/observe.md +2 -0
  21. package/package.json +1 -1
  22. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  23. package/src/observe/android-tree-bridge.ts +178 -0
  24. package/src/observe/android.ts +44 -17
  25. package/src/observe/ios.ts +45 -36
  26. package/src/observe/performance-metrics.ts +125 -0
  27. package/src/server/tool-handlers.ts +34 -10
  28. package/src/server-core.ts +1 -1
  29. package/src/utils/exec.ts +10 -4
  30. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  31. package/test/device/automated/ui-tree-performance/bridge-probe.ts +197 -0
  32. package/test/fixtures/latency-journey-app/AndroidManifest.xml +2 -1
  33. package/test/fixtures/latency-journey-app/README.md +19 -1
  34. package/test/fixtures/latency-journey-app/src/MainActivity.java +88 -1
  35. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  36. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  37. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +126 -0
  38. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  39. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +95 -0
  40. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  41. package/test/unit/observe/performance_metrics.test.ts +40 -0
  42. package/test/unit/server/operation_budget.test.ts +13 -0
  43. package/test/unit/server/run_journey.test.ts +30 -0
  44. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  45. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  46. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  47. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  48. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  49. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  50. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  51. /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
  52. /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
  53. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  54. /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
  55. /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
  56. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  57. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  58. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  59. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  60. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
  61. /package/docs/{specs-deltas/018-interaction-latency-and-batched-journeys.md → specs/018-interaction-latency-and-batched-journeys/spec.md} +0 -0
@@ -0,0 +1,148 @@
1
+ # Specification 019 — UI-Tree Observation Performance
2
+
3
+ ## Status
4
+
5
+ Implemented for Android with a paired emulator benchmark. iOS correctness has
6
+ unit and simulator smoke coverage; iOS latency remains unmeasured. See
7
+ `docs/benchmarks/ui-tree-performance/` for the initial baseline, paired reports,
8
+ and the retained failed first comparison attempt.
9
+
10
+ ## 1. Summary
11
+
12
+ Reduce the elapsed time spent obtaining accessibility trees during interaction journeys while preserving fresh-state verification. Measure physical hierarchy reads and their cost first. Then remove demonstrably redundant work within a single observation or journey, and shorten platform acquisition where it can be done without returning an older tree as new evidence.
13
+
14
+ No public tool, request field, default verification mode, or response shape changes in this specification.
15
+
16
+ ## 2. Current state and problem
17
+
18
+ Specification 018's checked-in Android benchmark reports a 51.8-second p50 `light`/`compact` journey on a Pixel 9 Pro Android 16 emulator. That is faster than its full/debug comparison, but still slow for an agent completing a short flow. The 018 report does not attribute the compact journey's time to individual hierarchy reads, so it does not establish how much of the remaining 51.8 seconds this work can save.
19
+
20
+ The present Android `getUITree` path obtains device metadata and display resolution, waits before an attempt, invokes `uiautomator dump`, reads the dumped XML, and parses it. The iOS path obtains device metadata, checks `idb`, waits, invokes `idb ui describe-all --json`, and parses the result. Both may retry. `wait_for_ui` and journey assertions can take multiple observations by design.
21
+
22
+ Specification 018 already passes the selector-resolution tree into `tapElementHandler` and, for a compact light tap immediately followed by an element-visible assertion, uses that assertion as the post-action observation. Spec 019 must retain those savings. It must not count a pre-action selector tree as proof of a post-action state, or collapse independent samples required by stabilization.
23
+
24
+ The roadmap's wait/synchronization work requires explicit freshness and reliable in-place UI updates. A network-backed screen can change without any MCP action. A time-based cross-request tree cache would therefore risk false assertions even if it improves a static fixture benchmark.
25
+
26
+ An agent report describes faster app start, tap dispatch, and screenshots, while reporting that `find_element` remains the slowest operation and that `verificationMode: "light"` often returns `VERIFICATION_UNAVAILABLE` after consuming its verification budget. The report provides no device, platform, sample count, request sequence, timeout, or raw measurements, so these figures are investigation leads and are not baseline evidence or acceptance results. In this specification, measure target resolution, dispatch, post-action observation, and assertion costs separately. A failed light observation remains the successful-delivery/incomplete-evidence outcome defined by Spec 018; this work must not relabel it as an action failure or silently remove the observation.
27
+
28
+ ## 3. Goals
29
+
30
+ 1. Attribute journey latency to physical hierarchy acquisition, retries, metadata/resolution work, parsing, waits, and assertions, without recording UI contents or device serials.
31
+ 2. Reduce physical reads or their per-read cost on the measured hot path.
32
+ 3. Preserve fresh observations after actions and during asynchronous, in-place UI changes.
33
+ 4. Preserve selector actionability, stale-reference protection, snapshot revisions/deltas, timeout handling, and existing verification modes.
34
+ 5. Demonstrate the resulting speed and correctness on both a static fixture flow and a controlled network-backed flow.
35
+
36
+ ## 4. Non-goals
37
+
38
+ - A cross-request or fixed-TTL cache of UI trees.
39
+ - Treating elapsed time alone as evidence that a tree is current.
40
+ - Weakening `expect_*` stabilization, two-sample checks, or the meaning of `light` and `full` verification.
41
+ - Changing screenshot, log, network, or debugger tools except for test instrumentation needed to distinguish their cost.
42
+ - Claiming an iOS latency gain from Android measurements.
43
+ - Depending on a live external service for the acceptance benchmark.
44
+
45
+ ## 5. Observation and reuse contract
46
+
47
+ A **physical tree read** is one invocation of Android `uiautomator dump` and retrieval of its XML, or one invocation of iOS `idb ui describe-all`. A retry is another physical read. Metadata queries, display-resolution queries, parsing, and selector evaluation are separate timing components. An **observation** is a successfully parsed tree from one physical read, with its platform, resolved device, capture completion time, and snapshot revision. Failed reads have an error but produce no reusable observation.
48
+
49
+ An **observation transaction** is an internal call that names all consumers before starting one physical acquisition. It ends when that acquisition returns or fails. A consumer added later, including a later journey step, starts a new transaction unless an existing Spec 018 path explicitly passes the same tree within the ongoing step. This boundary avoids silently making a later assertion depend on a tree captured before a network update. Consumers must be declared before acquisition starts; this spec does not authorize coalescing independent concurrent calls. Existing same-step explicit tree passing remains permitted.
50
+
51
+ The implementation may share one completed observation among consumers in the same observation transaction only if all of these hold:
52
+
53
+ - The consumers use the same resolved platform and device.
54
+ - No action dispatch, app lifecycle operation, scroll, or other operation capable of changing the visible hierarchy occurred between them.
55
+ - Neither consumer requests a later or independent sample. In particular, stabilization windows, `not_exists` confirmation, and screen-fingerprint convergence retain their required separate reads.
56
+ - A caller that requests post-action evidence receives a read started after that action's dispatch, never the tree used to find its target.
57
+ - An observation that failed, timed out, or was cancelled is never reused.
58
+
59
+ The implementation MUST NOT persist a reusable tree across MCP requests. It MUST NOT report a reused tree with a new `captured_at_ms` or `snapshot_revision`. This spec does not permit single-flight sharing across independently entered calls. If a future implementation proposes such sharing, it needs a separate contract for waiter cancellation, per-caller deadlines, acquisition deadline ownership, and process-exit confirmation before the sharing change is implemented.
60
+
61
+ If these conditions cannot be proven at a call site, perform a fresh read. The specification permits optimizing metadata queries, resolution queries, the acquisition command, or parsing instead of introducing observation reuse.
62
+
63
+ ## 6. Platform and failure behavior
64
+
65
+ The public `get_ui_tree`, `wait_for_ui`, selector tap, `expect_*`, `run_journey`, and debug-snapshot contracts remain as defined by existing specs. Compact responses continue to omit debug timing components. Diagnostic measurements are collected by the benchmark harness or an internal, opt-in test hook; they are not added to compact tool responses.
66
+
67
+ An expired operation or verification budget stops polling and terminates the active device command under the existing `ACTION_TIMEOUT` or verification-unavailable rules. It does not leave a background tree read that can overlap the next action. For this contract, termination is complete only when the spawned hierarchy command has emitted its close/exit event; sending a kill signal alone does not satisfy it. The caller must not start a subsequent device action until termination is confirmed. A failed acquisition keeps the existing observable error and retry behavior. If an optimization cannot supply a qualifying fresh tree, it falls back to the existing acquisition path within the same remaining deadline. The fallback must not start after the deadline.
68
+
69
+ Tree revisions and deltas are derived only from actual observations. Sharing one observation cannot create a new revision or a second stability sample. Device identity is resolved before any sharing decision; observations from different emulators, simulators, or platforms are isolated.
70
+
71
+ ## 7. Measurement contract
72
+
73
+ Add internal instrumentation at the adapter boundary and journey call sites. For each measured run, record counts and elapsed milliseconds for:
74
+
75
+ | Field | Meaning |
76
+ |---|---|
77
+ | `physical_tree_reads` | Number of platform hierarchy commands started, including retries. |
78
+ | `tree_read_ms` | Android dump plus XML retrieval, or the iOS hierarchy command, from start to exit. |
79
+ | `metadata_ms` | Device metadata and capability checks performed for tree acquisition. |
80
+ | `capability_check_ms` | Platform tooling availability checks, when measured separately from metadata. |
81
+ | `resolution_ms` | Display-size acquisition, where applicable. |
82
+ | `parse_ms` | Parsing and conversion to the public element model. |
83
+ | `observation_wait_ms` | Intentional pre-read waits and polling delays. |
84
+ | `target_resolution_ms` | Time spent finding, selecting, and validating an actionable target before dispatch. |
85
+ | `dispatch_ms` | Time from action dispatch start until the platform action command completes. |
86
+ | `verification_budget_ms` | Effective light-verification budget for the action. |
87
+ | `verification_elapsed_ms` | Time spent obtaining the light post-action observation, including waits and failed attempts. |
88
+ | `verification_outcome` | `observed`, `unavailable`, or `not_requested`, matching the public action outcome. |
89
+ | `reused_observations` | Consumers served by an eligible existing observation. |
90
+ | `failed_tree_reads` | Commands that failed, timed out, or were cancelled. |
91
+
92
+ The measurement record also includes the journey step ID, observation purpose (`target`, `verification`, `assertion`, or `debug`), platform, and whether the read started before or after the preceding dispatch. Each physical acquisition has one unique internal acquisition ID and is recorded exactly once; each consumer use has a separate consumer record referring to that ID. When one acquisition serves multiple purposes, record one acquisition and all consumer records; do not duplicate its duration or physical-read count in aggregate totals. Count `failed_tree_reads` once for each hierarchy command that fails to start, exits unsuccessfully, times out, or is cancelled before successful output retrieval. A successful hierarchy command followed by empty/invalid output is a failed acquisition and is counted once. For light verification, record whether the observation budget expired, how much of the effective budget elapsed, and the associated acquisition IDs, including failed attempts. This data is used to determine whether `VERIFICATION_UNAVAILABLE` is driven by command acquisition, metadata, intentional waits, parsing, or the configured budget. The report groups acquisition cost and consumer count by step and purpose; raw timestamps need not be checked in. It MUST NOT include tree nodes, UI text, app identifiers, screenshots, logs, network payloads, or device serials.
93
+
94
+ The harness measures the full MCP request from write to complete response as in Spec 018. Timing components need not sum exactly to wall time where work overlaps, but the report must identify overlap and use wall time for performance gates.
95
+
96
+ ## 8. Reproducible benchmark flows
97
+
98
+ Create `test/device/automated/ui-tree-performance/` and `docs/benchmarks/ui-tree-performance/`. Build on Spec 018's spawned `dist/server.js` client and Android fixture rather than introducing a second transport or test framework. First land only instrumentation and the benchmark fixture; capture that clean commit as the baseline using `benchmark.ts --baseline-only`, before changing acquisition behavior. The baseline-only run writes `baseline.json`. After choosing and implementing an optimization, measure the candidate against the same instrumentation, fixture APK SHA-256, emulator image, display settings, and host configuration. Record both commit SHAs, server version, platform/model/OS/API, command timeout, sample counts, failures, and request sequences.
99
+
100
+ The harness accepts `UI_TREE_BASELINE_ROOT`, `UI_TREE_CANDIDATE_ROOT`, and `LATENCY_DEVICE_ID`. Each root must be a clean, built checkout containing `dist/server.js`; missing or dirty inputs fail before a device reset. It starts each server over stdio from the supplied root, uses the single candidate fixture APK for both modes, and writes `baseline.json`, `candidate.json`, and `comparison.md` under `docs/benchmarks/ui-tree-performance/`. A reviewer can reproduce the comparison with:
101
+
102
+ ```sh
103
+ UI_TREE_BASELINE_ROOT=/path/to/instrumented-baseline \
104
+ UI_TREE_CANDIDATE_ROOT=/path/to/optimized-candidate \
105
+ LATENCY_DEVICE_ID=emulator-5554 \
106
+ npx tsx test/device/automated/ui-tree-performance/benchmark.ts
107
+ ```
108
+
109
+ Two flows are required:
110
+
111
+ 1. **Static journey:** the Spec 018 navigation/control journey using `light`/`compact`, with the same actions and assertions in baseline and candidate. It isolates hierarchy overhead without network variance.
112
+ 2. **Network-backed in-place update:** a deterministic local HTTP server drives an Android fixture screen from `loading` to `ready` after a configured delay, without navigation or a second MCP action. The fixture exposes stable accessibility IDs `loading`, `ready`, and `value`. For each attempt, the harness starts the fixture and server in a known reset state. The server holds the response until a completed MCP observation contains `loading` and contains neither `ready` nor `value`; it then starts the configured delay and sends `ready` with a run-unique value in the same response, recording a monotonic send timestamp and run ID. The harness then issues a fresh observation request after the response send event and asserts that one observation contains both `ready` and the matching run-unique value. A read that started before the response send event cannot satisfy this assertion even if it completes afterward. Run one response delay of 100 ms and one of 1,500 ms. Use emulator-to-host routing supplied by the harness; do not call a public service.
113
+
114
+ Both flows run sequentially, with three warm-ups followed by exactly ten measured attempts per flow, response-delay setting, and baseline/candidate mode. Failed runs are counted and excluded from percentiles, with the failed step and selector reported; any failed measured attempt fails acceptance. Screenshots and debug snapshots are outside the timed journeys and may be reported separately. Baseline and candidate order should alternate by run pair to limit emulator drift; the fixture and local server are reset before every run. The reports must state the timing boundary for every metric and include per-attempt acquisition/consumer records so the aggregation rules in Section 7 can be reproduced.
115
+
116
+ For iOS, automated unit tests with controlled `idb` output and the existing simulator smoke tests must cover correctness and cancellation. An iOS latency claim requires a reproducible iOS fixture and the same paired protocol; until then, report iOS performance as unmeasured.
117
+
118
+ ## 9. Acceptance criteria
119
+
120
+ 1. The baseline report identifies the physical-read count and p50/p95 time for each purpose and journey step, and distinguishes hierarchy command time from waits and parsing. It reports target-resolution, dispatch, and light-verification elapsed time and outcomes, including the rate and elapsed budget for `VERIFICATION_UNAVAILABLE`.
121
+ 2. On the static Android journey, candidate p50 end-to-end time improves by at least 15% against its paired baseline, with no more than 5% p95 regression. At least one measured hot-path component must account for the gain; a response-size-only gain does not satisfy this spec.
122
+ 3. On both network-backed delay settings, candidate p95 end-to-end time does not regress by more than 5%. The ready/value assertions never pass before the corresponding server response, and no run passes using a pre-update tree. The request start timestamp for the observation supplying the assertion must be later than that attempt's recorded response-send timestamp.
123
+ 4. Baseline and candidate have zero assertion failures across the ten measured attempts per flow. Every failed warm-up or measured attempt remains visible in the report; no failed run contributes to a percentile.
124
+ 5. Focused tests prove that a post-action assertion gets a post-dispatch read, two-sample stabilization uses two physical reads, device isolation holds, a failed/cancelled observation is not reused, and a budget expiry waits for hierarchy child-process exit before a subsequent device action can begin. Tests also prove one physical acquisition is counted once when explicitly passed to multiple same-step consumers, while all consumers receive records linked to that acquisition.
125
+ 6. `npm run verify` and `npm run test:device` pass. Existing Spec 018 response-shape and journey tests continue to pass. The checked-in JSON and Markdown reports identify exactly which platforms and flows were measured.
126
+
127
+ If the 15% gate cannot be met without weakening freshness or verification, stop the optimization and report the measured bottleneck and failed gate. Do not lower the gate or mark this specification implemented on the strength of a synthetic unit benchmark.
128
+
129
+ ## 10. Implementation plan and ownership
130
+
131
+ 1. Add measurement hooks and reproduce the baseline before changing acquisition behavior. Validate the counters against a fake Android `adb` and fake iOS `idb` command so retries, invalid output, termination, and cancellations are counted once per actual acquisition and child-process exit is observable before the next action.
132
+ 2. Use the baseline breakdown to select one or more changes in `src/observe/android.ts`, `src/observe/ios.ts`, `src/observe/index.ts`, or `src/interact/index.ts`. Document why each changed read is redundant or why its replacement is equivalently fresh.
133
+ 3. Preserve the contracts in Section 5 with focused unit tests under `test/unit/observe/` and `test/unit/interact/`. Add the controlled network fixture and harness under `test/device/automated/`.
134
+ 4. Run the paired benchmarks and required repository gates. Check in baseline and candidate reports, update tool documentation only if user-visible behavior changes, and record any unmeasured platform separately.
135
+
136
+ The benchmark and fixture belong to this repository. No backend or third-party service is required. Device testing requires the Android SDK/emulator and, for iOS smoke checks, a booted simulator with `idb`; inability to access either is an execution blocker for that platform's acceptance check, not evidence that it passed.
137
+
138
+ ## 11. Baseline decision and result
139
+
140
+ The initial Android baseline found 20 physical tree reads in each measured
141
+ static journey, including independent assertion samples, and an approximately
142
+ 400 ms intentional wait before every first attempt. No cross-request tree
143
+ reuse was added. The candidate starts the first Android read immediately and
144
+ retains the wait for retries. The clean repeat paired run improved static p50
145
+ from 52.40 s to 43.82 s (16.4%) with no network p95 regression or failed
146
+ attempts. A prior paired attempt had two network failures and is retained in
147
+ the benchmark directory; its cause remains unconfirmed. This result does not
148
+ claim that `VERIFICATION_UNAVAILABLE` is eliminated in other apps.
@@ -92,6 +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 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`.
95
97
 
96
98
  ---
97
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.32.0",
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": {
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: mobile-debug-mcp
3
+ description: Verify Android app behaviour efficiently with mobile-debug MCP, especially session playback, background work, and notifications. Use for emulator/device QA; do not use for implementation-only checks.
4
+ metadata:
5
+ author: modul8-team
6
+ version: "1.0"
7
+ ---
8
+
9
+ # Mobile-debug MCP verification
10
+
11
+ Use mobile-debug MCP to make a product claim observable on a real device or emulator. A successful tap proves only that the input was delivered; verify the resulting UI, notification, or persisted state before reporting success.
12
+
13
+ ## Choose the lightest evidence that proves the claim
14
+
15
+ - Use an accessibility assertion such as `expect_element_visible` or
16
+ `wait_for_ui` for an accessible label, enabled state, or changed control.
17
+ - Use a screenshot when visual layout, system UI, or notification content matters.
18
+ - Use a debug snapshot only when the screen does not explain a failed action or an accessibility-tree assertion is needed.
19
+ - Use Android shell commands only for device navigation or system surfaces that MCP cannot expose. Do not treat shell output as proof of user-visible behaviour.
20
+
21
+ Avoid repeated screenshots and snapshots when a targeted accessibility assertion is enough.
22
+
23
+ ## Keep interaction fast
24
+
25
+ Treat each MCP call as a round trip and use the fastest path that still proves
26
+ the behaviour. Prefer the following sequence:
27
+
28
+ 1. Wait for one stable, uniquely identifiable control.
29
+ 2. Tap its returned element id.
30
+ 3. Wait for the specific resulting state.
31
+
32
+ Do not wait before every tap when the current screen and element id are already known. Do not poll unchanged screens. Use short timeouts for ordinary controls; reserve longer waits for known asynchronous work such as model initialisation or network calls.
33
+
34
+ Use compact responses for routine actions. If action dispatch is reliable but
35
+ verification is slow or unavailable, dispatch with verification disabled and
36
+ verify at meaningful checkpoints with one focused accessibility assertion or
37
+ screenshot. Do not assume `light` verification is faster: use it only when it
38
+ has returned useful results reliably on the current MCP version and device.
39
+
40
+ Prefer a stable element selector when the accessibility tree exposes one. Use
41
+ coordinates only when needed, and take a fresh screenshot immediately before a
42
+ coordinate tap on system UI, notifications, or any screen where layout may have
43
+ changed. Never reuse coordinates after expanding/collapsing a notification,
44
+ changing orientation, or switching screens.
45
+
46
+ Element discovery can be the slowest part of a journey. Avoid repeated
47
+ `find_element` calls for the same screen: resolve a control once, use the
48
+ returned selector or coordinates while that screen is unchanged, and verify
49
+ the resulting state before continuing. If a query fails, inspect the current
50
+ screen once before retrying; controls represented only by icons may not have a
51
+ matching visible text label.
52
+
53
+ When the MCP offers a journey or batch action, use it only when its steps can
54
+ express the needed observable checks and it returns enough evidence to report
55
+ the outcome. Measure the whole journey and compare equivalent steps on the same
56
+ device; do not infer a performance improvement from a single fast action.
57
+
58
+ ## Session playback checks
59
+
60
+ For a claimed playback start, observe a running timer or Pause state after Play. For a claimed pause, observe Resume and unchanged elapsed time. For a claimed stop, observe that playback ends and the notification disappears.
61
+
62
+ For background playback, press Home, open the notification shade, and verify
63
+ the notification title, progress/status, and available actions. Expand the
64
+ session notification if necessary, then capture its current layout before
65
+ tapping an action. Exercise each action needed for the change and observe its
66
+ result: Pause becomes Resume; Resume returns to active playback; Stop ends
67
+ playback and clears the notification. After each action, reacquire the current
68
+ screen state before using another coordinate.
69
+
70
+ Do not claim spoken guidance was verified unless audio output was actually inspected. A running timer or active notification verifies playback state, not audible wording.
71
+
72
+ ## Report evidence precisely
73
+
74
+ State the device journey, observed result, and any untested scope. Mention whether evidence came from an accessibility assertion, screenshot, or system notification. Do not say a feature works solely because the build compiled or an MCP action returned success.
@@ -0,0 +1,178 @@
1
+ import { execFile, spawn, spawnSync, type ChildProcess } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { connect } from 'node:net'
4
+ import path from 'node:path'
5
+ import { execAdb, getAdbCmd } from '../utils/android/utils.js'
6
+ import { remainingBudget } from '../utils/operation-budget.js'
7
+
8
+ const packageName = 'dev.mobiledebugmcp.treebridge'
9
+ const component = `${packageName}/.TreeInstrumentation`
10
+ const devicePort = 39019
11
+
12
+ interface Bridge {
13
+ deviceId: string
14
+ hostPort: number
15
+ child: ChildProcess
16
+ closed: boolean
17
+ childClosed: boolean
18
+ }
19
+
20
+ const active = new Map<string, Bridge>()
21
+ const starting = new Map<string, Promise<Bridge>>()
22
+ const stopping = new Map<string, Promise<void>>()
23
+ const bridgeSupport = new Map<string, boolean>()
24
+
25
+ export function androidTreeBridgeEnabled(): boolean {
26
+ return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK)
27
+ }
28
+
29
+ function configuredApk() {
30
+ const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK
31
+ if (!configured) return null
32
+ const apk = path.resolve(configured)
33
+ if (!existsSync(apk)) throw new Error(`Android UI tree bridge APK does not exist: ${apk}`)
34
+ return apk
35
+ }
36
+
37
+ function unbudgetedAdb(deviceId: string, args: string[]): Promise<void> {
38
+ return new Promise((resolve, reject) => {
39
+ execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve())
40
+ })
41
+ }
42
+
43
+ function request(bridge: Bridge, command: 'PING' | 'TREE' | 'STOP', timeoutMs: number): Promise<string> {
44
+ return new Promise((resolve, reject) => {
45
+ let output = ''
46
+ const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`))
47
+ socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)))
48
+ socket.on('data', (chunk) => {
49
+ output += chunk.toString()
50
+ if (command === 'STOP' && output.startsWith('OK\n')) {
51
+ resolve(output)
52
+ socket.destroy()
53
+ }
54
+ })
55
+ socket.on('error', reject)
56
+ socket.on('end', () => resolve(output))
57
+ })
58
+ }
59
+
60
+ function waitForChildClose(bridge: Bridge, timeoutMs: number): Promise<void> {
61
+ if (bridge.childClosed) return Promise.resolve()
62
+ return new Promise((resolve, reject) => {
63
+ const onClose = () => { clearTimeout(timer); resolve() }
64
+ const timer = setTimeout(() => {
65
+ bridge.child.off('close', onClose)
66
+ reject(new Error('Android UI tree bridge child did not exit'))
67
+ }, timeoutMs)
68
+ bridge.child.once('close', onClose)
69
+ })
70
+ }
71
+
72
+ async function stop(bridge: Bridge): Promise<void> {
73
+ const pending = stopping.get(bridge.deviceId)
74
+ if (pending) return pending
75
+ bridge.closed = true
76
+ const cleanup = (async () => {
77
+ await request(bridge, 'STOP', 1000).catch(() => '')
78
+ let forceStopError: unknown
79
+ try { await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]) }
80
+ catch (error) { forceStopError = error }
81
+ try { await waitForChildClose(bridge, 1000) }
82
+ catch {
83
+ bridge.child.kill('SIGTERM')
84
+ try { await waitForChildClose(bridge, 1000) }
85
+ catch {
86
+ bridge.child.kill('SIGKILL')
87
+ await waitForChildClose(bridge, 2000).catch(() => {})
88
+ }
89
+ }
90
+ let forwardError: unknown
91
+ try { await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]) }
92
+ catch (error) { forwardError = error }
93
+ if (forceStopError || !bridge.childClosed || forwardError) {
94
+ const reason = forceStopError ?? (!bridge.childClosed ? 'instrumentation child did not close' : forwardError)
95
+ throw new Error(`BRIDGE_CLEANUP_UNCONFIRMED: ${reason instanceof Error ? reason.message : String(reason)}`)
96
+ }
97
+ if (active.get(bridge.deviceId) === bridge) active.delete(bridge.deviceId)
98
+ })()
99
+ stopping.set(bridge.deviceId, cleanup)
100
+ try { await cleanup } finally { stopping.delete(bridge.deviceId) }
101
+ }
102
+
103
+ async function launch(deviceId: string, apk: string): Promise<Bridge> {
104
+ await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName])
105
+ try {
106
+ await execAdb(['install', '-r', apk], deviceId)
107
+ } catch (error) {
108
+ if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error
109
+ await execAdb(['uninstall', packageName], deviceId)
110
+ await execAdb(['install', '-r', apk], deviceId)
111
+ }
112
+ const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId))
113
+ if (!Number.isInteger(hostPort) || hostPort <= 0) throw new Error('Android UI tree bridge could not obtain a host port')
114
+ const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' })
115
+ const bridge: Bridge = { deviceId, hostPort, child, closed: false, childClosed: false }
116
+ child.once('close', () => { bridge.childClosed = true })
117
+ child.once('error', () => { /* Startup detects the failed child before a tree read. */ })
118
+ active.set(deviceId, bridge)
119
+ try {
120
+ const deadline = Date.now() + Math.min(remainingBudget(3000), 3000)
121
+ while (Date.now() < deadline) {
122
+ if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null) throw new Error('Android UI tree bridge exited during startup')
123
+ try {
124
+ if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
125
+ return bridge
126
+ }
127
+ } catch { /* The instrumentation process may still be starting. */ }
128
+ await new Promise((resolve) => setTimeout(resolve, 100))
129
+ }
130
+ throw new Error('Android UI tree bridge did not become ready')
131
+ } catch (error) {
132
+ await stop(bridge)
133
+ throw error
134
+ }
135
+ }
136
+
137
+ async function ensure(deviceId: string, apk: string): Promise<Bridge> {
138
+ const pending = starting.get(deviceId)
139
+ if (pending) return pending
140
+ const ready = active.get(deviceId)
141
+ if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null) return ready
142
+ if (ready) await stop(ready)
143
+ const promise = launch(deviceId, apk)
144
+ starting.set(deviceId, promise)
145
+ try { return await promise } finally { starting.delete(deviceId) }
146
+ }
147
+
148
+ /** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
149
+ export async function readAndroidTreeFromBridge(deviceId: string): Promise<string | null> {
150
+ const apk = configuredApk()
151
+ if (!apk) return null
152
+ let bridge: Bridge | undefined
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
161
+ bridge = await ensure(deviceId, apk)
162
+ const output = await request(bridge, 'TREE', remainingBudget(10000))
163
+ if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) throw new Error('Android UI tree bridge returned invalid hierarchy output')
164
+ return output.slice(3)
165
+ } catch (error) {
166
+ if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')) throw error
167
+ if (bridge) await stop(bridge)
168
+ if (error instanceof Error && error.message === 'ACTION_TIMEOUT') throw error
169
+ return null
170
+ }
171
+ }
172
+
173
+ process.once('exit', () => {
174
+ for (const bridge of active.values()) {
175
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' })
176
+ spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' })
177
+ }
178
+ })
@@ -9,6 +9,8 @@ import { computeScreenFingerprint } from "../utils/ui/index.js"
9
9
  import { parsePngSize } from "../utils/image.js"
10
10
  import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
11
11
  import { remainingBudget } from '../utils/operation-budget.js'
12
+ import { createObservationMeasurement } from './performance-metrics.js'
13
+ import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js'
12
14
 
13
15
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
14
16
 
@@ -18,12 +20,20 @@ export class AndroidObserve {
18
20
  }
19
21
 
20
22
  async getUITree(deviceId?: string): Promise<GetUITreeResponse> {
21
- const metadata = await getAndroidDeviceMetadata("", deviceId)
23
+ const measurement = createObservationMeasurement('android')
24
+ let metadata
25
+ try {
26
+ metadata = await measurement.timed('metadata_ms', () => getAndroidDeviceMetadata("", deviceId))
27
+ } catch (error) {
28
+ measurement.setOutcome('unavailable')
29
+ measurement.finish()
30
+ throw error
31
+ }
22
32
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
23
33
 
24
34
  try {
25
35
  // Get screen resolution first
26
- const resolution = await getScreenResolution(deviceId);
36
+ const resolution = await measurement.timed('resolution_ms', () => getScreenResolution(deviceId));
27
37
  if (resolution.width === 0 && resolution.height === 0) {
28
38
  throw new Error("Failed to get screen resolution. Is the device connected and authorized?");
29
39
  }
@@ -37,22 +47,34 @@ export class AndroidObserve {
37
47
  remainingBudget()
38
48
  attempts++;
39
49
  try {
40
- // Stabilization delay
41
- await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
50
+ // The first read starts immediately so it remains fresh for in-place UI changes.
51
+ // Delay only a retry after a failed or invalid acquisition.
52
+ if (attempts > 1) {
53
+ await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
54
+ }
42
55
  remainingBudget()
43
56
 
44
- // Dump UI hierarchy
45
- await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
46
-
47
- // Read the file
48
- xmlContent = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
49
-
50
- // Check validity
51
- if (xmlContent && xmlContent.trim().length > 0 && !xmlContent.includes("ERROR:")) {
52
- break; // Success
57
+ let bridgeContent: string | null = null
58
+ if (androidTreeBridgeEnabled()) {
59
+ try {
60
+ bridgeContent = await measurement.physicalRead(async () => {
61
+ const content = await readAndroidTreeFromBridge(deviceInfo.id)
62
+ if (!content) throw new Error('Android UI tree bridge unavailable')
63
+ return content
64
+ })
65
+ } catch (error) {
66
+ if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw error
67
+ }
53
68
  }
69
+ xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
70
+ await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId)
71
+ const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId)
72
+ if (!content || !content.trim() || content.includes('ERROR:')) throw new Error('Empty or invalid UI dump')
73
+ return content
74
+ })
75
+ break
54
76
  } catch (e) {
55
- if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
77
+ if (e instanceof Error && (e.message === 'ACTION_TIMEOUT' || e.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw e
56
78
  console.error(`Attempt ${attempts} failed: ${e}`);
57
79
  }
58
80
 
@@ -65,7 +87,7 @@ export class AndroidObserve {
65
87
  ignoreAttributes: false,
66
88
  attributeNamePrefix: "@_"
67
89
  });
68
- const result = parser.parse(xmlContent);
90
+ const result = await measurement.timed('parse_ms', async () => parser.parse(xmlContent));
69
91
 
70
92
  const elements: UIElement[] = [];
71
93
 
@@ -85,17 +107,22 @@ export class AndroidObserve {
85
107
  elements
86
108
  }, 'ui_tree')
87
109
 
88
- return {
110
+ measurement.setOutcome('observed')
111
+ const response = measurement.bindObservation({
89
112
  device: deviceInfo,
90
113
  screen: "",
91
114
  resolution,
92
115
  elements,
93
116
  ...snapshotMetadata
94
- };
117
+ })
118
+ measurement.finish()
119
+ return response
95
120
  } catch (e) {
96
121
  const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
97
122
  console.error(errorMessage);
98
123
  const snapshotMetadata = deriveSnapshotMetadata(`android:${deviceInfo.id}`, null, 'ui_tree')
124
+ measurement.setOutcome('unavailable')
125
+ measurement.finish()
99
126
  return {
100
127
  device: deviceInfo,
101
128
  screen: "",