mobile-debug-mcp 0.32.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.cursor-plugin/marketplace.json +16 -0
  4. package/.github/copilot-instructions.md +7 -0
  5. package/.github/workflows/ci.yml +21 -0
  6. package/AGENTS.md +21 -10
  7. package/README.md +69 -4
  8. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  9. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  10. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  11. package/agent-plugin/.mcp.json +14 -0
  12. package/agent-plugin/README.md +9 -3
  13. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  14. package/agent-plugin/mcp.json +1 -1
  15. package/agent-plugin/plugin.json +1 -1
  16. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
  17. package/dist/debugger/android.js +78 -0
  18. package/dist/debugger/guard.js +30 -0
  19. package/dist/debugger/sessions.js +262 -0
  20. package/dist/debugger/worker.js +165 -0
  21. package/dist/interact/android.js +10 -4
  22. package/dist/interact/conditional-action.js +68 -0
  23. package/dist/interact/index.js +70 -14
  24. package/dist/interact/ios.js +14 -8
  25. package/dist/manage/android.js +4 -3
  26. package/dist/manage/ios.js +3 -2
  27. package/dist/observe/android.js +25 -4
  28. package/dist/observe/evidence.js +280 -0
  29. package/dist/observe/index.js +33 -6
  30. package/dist/observe/ios.js +25 -6
  31. package/dist/observe/readiness-service.js +124 -0
  32. package/dist/observe/readiness.js +88 -0
  33. package/dist/observe/recording.js +165 -0
  34. package/dist/observe/snapshot-metadata.js +10 -2
  35. package/dist/observe/system-trace-android.js +135 -0
  36. package/dist/observe/system-trace.js +285 -0
  37. package/dist/server/common.js +9 -0
  38. package/dist/server/condition-schema.js +32 -0
  39. package/dist/server/debugger-handlers.js +38 -0
  40. package/dist/server/debugger-schema.js +42 -0
  41. package/dist/server/journey-schema.js +4 -2
  42. package/dist/server/readiness-schema.js +12 -0
  43. package/dist/server/system-trace-schema.js +17 -0
  44. package/dist/server/tool-definitions.js +51 -0
  45. package/dist/server/tool-handlers.js +294 -105
  46. package/dist/server-core.js +3 -3
  47. package/dist/server.js +34 -0
  48. package/dist/system/index.js +5 -4
  49. package/dist/utils/exec.js +2 -0
  50. package/dist/utils/ios/utils.js +4 -4
  51. package/dist/utils/java.js +14 -12
  52. package/dist/utils/operation-budget.js +26 -2
  53. package/dist/utils/resolve-device.js +9 -0
  54. package/docs/CHANGELOG.md +15 -0
  55. package/docs/specs/020-conditional-actions/intent.md +62 -0
  56. package/docs/specs/020-conditional-actions/plan.md +214 -0
  57. package/docs/specs/020-conditional-actions/spec.md +301 -0
  58. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  59. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  60. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  61. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  62. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  63. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  64. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  65. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  66. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  67. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  68. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  69. package/docs/specs/023-android-system-traces/intent.md +40 -0
  70. package/docs/specs/023-android-system-traces/plan.md +117 -0
  71. package/docs/specs/023-android-system-traces/spec.md +120 -0
  72. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  73. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  74. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  75. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  76. package/docs/specs/process.md +213 -0
  77. package/docs/tools/TOOLS.md +5 -0
  78. package/docs/tools/debugger.md +104 -0
  79. package/docs/tools/evidence.md +82 -0
  80. package/docs/tools/interact.md +125 -2
  81. package/docs/tools/observe.md +70 -0
  82. package/docs/tools/system-traces.md +126 -0
  83. package/package.json +1 -1
  84. package/scripts/agent-plugin.mjs +68 -8
  85. package/src/debugger/android.ts +65 -0
  86. package/src/debugger/guard.ts +26 -0
  87. package/src/debugger/sessions.ts +168 -0
  88. package/src/debugger/worker.ts +122 -0
  89. package/src/interact/android.ts +10 -4
  90. package/src/interact/conditional-action.ts +75 -0
  91. package/src/interact/index.ts +57 -28
  92. package/src/interact/ios.ts +14 -8
  93. package/src/manage/android.ts +4 -3
  94. package/src/manage/ios.ts +3 -2
  95. package/src/observe/android.ts +33 -6
  96. package/src/observe/evidence.ts +262 -0
  97. package/src/observe/index.ts +32 -8
  98. package/src/observe/ios.ts +32 -8
  99. package/src/observe/readiness-service.ts +94 -0
  100. package/src/observe/readiness.ts +84 -0
  101. package/src/observe/snapshot-metadata.ts +12 -1
  102. package/src/observe/system-trace-android.ts +133 -0
  103. package/src/observe/system-trace.ts +218 -0
  104. package/src/server/common.ts +7 -0
  105. package/src/server/condition-schema.ts +32 -0
  106. package/src/server/debugger-handlers.ts +29 -0
  107. package/src/server/debugger-schema.ts +37 -0
  108. package/src/server/journey-schema.ts +4 -2
  109. package/src/server/readiness-schema.ts +12 -0
  110. package/src/server/system-trace-schema.ts +17 -0
  111. package/src/server/tool-definitions.ts +49 -0
  112. package/src/server/tool-handlers.ts +182 -21
  113. package/src/server-core.ts +3 -3
  114. package/src/server.ts +24 -0
  115. package/src/system/index.ts +7 -5
  116. package/src/types.ts +30 -1
  117. package/src/utils/exec.ts +2 -0
  118. package/src/utils/ios/utils.ts +4 -4
  119. package/src/utils/java.ts +13 -12
  120. package/src/utils/operation-budget.ts +24 -2
  121. package/src/utils/resolve-device.ts +10 -0
  122. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  123. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  124. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  125. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  126. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  127. package/test/device/manual/interact/readiness.manual.ts +99 -0
  128. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  129. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  130. package/test/fixtures/debugger-app/README.md +13 -0
  131. package/test/fixtures/debugger-app/build.ts +29 -0
  132. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  133. package/test/fixtures/readiness-app/README.md +12 -0
  134. package/test/fixtures/readiness-app/build.ts +37 -0
  135. package/test/fixtures/readiness-app/main.m +27 -0
  136. package/test/helpers/perfetto.ts +59 -0
  137. package/test/unit/debugger/guards.test.ts +46 -0
  138. package/test/unit/debugger/native.test.ts +33 -0
  139. package/test/unit/debugger/sessions.test.ts +83 -0
  140. package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
  141. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  142. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  143. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  144. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  145. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  146. package/test/unit/observe/readiness.test.ts +41 -0
  147. package/test/unit/observe/readiness_native.test.ts +68 -0
  148. package/test/unit/observe/system-trace-android.test.ts +52 -0
  149. package/test/unit/observe/system-trace.test.ts +166 -0
  150. package/test/unit/server/conditional_actions.test.ts +299 -0
  151. package/test/unit/server/conditional_schema.test.ts +59 -0
  152. package/test/unit/server/correlated-evidence.test.ts +194 -0
  153. package/test/unit/server/readiness.test.ts +170 -0
  154. package/test/unit/server/selector_tap_target.test.ts +79 -0
  155. package/test/unit/server/system-trace.test.ts +64 -0
  156. package/test/unit/server/tap_selector.test.ts +2 -0
  157. package/worker/DebuggerWorker.java +160 -0
  158. package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
  159. /package/{skills → .agents/skills}/README.md +0 -0
  160. /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
  161. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  162. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  163. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  164. /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
  165. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  166. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  167. /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
  168. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  169. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  170. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -19,6 +19,90 @@ Important:
19
19
  - action tools report execution success, not outcome correctness.
20
20
  - `classify_action_outcome` should receive the runtime `action_type` when you want routing to distinguish local-state and side-effect actions.
21
21
 
22
+ ## Conditional actions
23
+
24
+ `tap`, `tap_element`, and `swipe` (Android/iOS), plus Android `type_text` and
25
+ `press_back`, accept an optional expected UI snapshot. Journey `tap` steps accept
26
+ it per step. Other tools and step types reject `expectedState` rather than ignore
27
+ it; it is not supported in journey defaults or on the journey itself.
28
+
29
+ First call `get_ui_tree` on the chosen device. From a successful response, use
30
+ `snapshot_session_id` and `snapshot_revision`:
31
+
32
+ ```json
33
+ {
34
+ "platform": "android",
35
+ "deviceId": "emulator-5554",
36
+ "selector": { "accessibility_id": "save" },
37
+ "expectedState": {
38
+ "snapshotSessionId": "<snapshot_session_id from get_ui_tree>",
39
+ "snapshotRevision": 12,
40
+ "timeoutMs": 15000
41
+ },
42
+ "verificationMode": "none",
43
+ "responseMode": "compact"
44
+ }
45
+ ```
46
+
47
+ This is a `tap_element` example. Guarded standalone calls require explicit
48
+ `platform` and concrete `deviceId` from the observed tree; `booted` and `default`
49
+ are not valid guarded targets. Journey taps inherit the journey's resolved device.
50
+ The expected-state object is strict: nonempty session ID, positive safe-integer
51
+ revision, and optional integer timeout of 100–60000 ms (default 15000). Invalid or
52
+ unsupported conditions return an `INVALID_ARGUMENT` error before device work.
53
+ Calls without a condition preserve existing behaviour.
54
+
55
+ After target and adapter preparation, the server reads a new hierarchy just
56
+ before native input. It checks the same device, server session, and retained
57
+ revision, even with post-action verification disabled. It never waits for a
58
+ mismatch to become a match or retries/rebases the input automatically.
59
+
60
+ | Result | Meaning |
61
+ | --- | --- |
62
+ | `condition.status: matched` | Condition passed; inspect the ordinary action outcome for delivery and verify its effect separately. |
63
+ | `PRECONDITION_FAILED`, `condition.status: mismatch` | State changed; input was not dispatched. The condition includes the exact fresh tree that blocked it. |
64
+ | `PRECONDITION_UNAVAILABLE`, `condition.status: unavailable` | Condition could not be established; input was not dispatched. Inspect `condition.reason` and observe again. |
65
+ | `condition.status: not_checked` | Target/capability preparation failed before reaching the guard; inspect the original action failure. |
66
+
67
+ Blocked input reports `success: false`, `dispatch_started: false`,
68
+ `delivery_status: not_delivered`, `retryable: false`, `lifecycle_state: failed`,
69
+ and `verification.status: not_requested`. No network action window is reset and
70
+ no post-input verification runs. A failed conditional journey tap stops the
71
+ journey; subsequent steps are `not_run` and earlier steps are not rolled back.
72
+
73
+ Condition evidence has `status`, `expected: { snapshot_session_id,
74
+ snapshot_revision }`, `observed`, `elapsed_ms`, and an optional `reason`.
75
+ Reasons are `BASELINE_UNAVAILABLE`, `SESSION_MISMATCH`, `DEVICE_MISMATCH`,
76
+ `OBSERVATION_FAILED`, `OBSERVATION_INVALID`, `CONDITION_TIMEOUT`, or
77
+ `ACTION_NOT_REACHED` for a check that was not reached. `observed` is null when
78
+ there is no valid fresh observation. Error trees cannot authorise input merely
79
+ because they contain an old revision.
80
+
81
+ Debug responses retain the full condition tree. Compact responses retain full
82
+ fresh evidence on mismatch/unavailable results; this is an exception to their
83
+ usual omission of device identifiers and tree contents. Successful compact
84
+ conditions contain only observed session/revision and capture time. UI trees may
85
+ contain private text; avoid copying them into logs unnecessarily.
86
+
87
+ The baseline must still be in this process's same-device history: up to eight
88
+ revisions, retained for ten minutes since a successful observation of that
89
+ revision. Server restart or eviction requires a new observation. A fresh read
90
+ cannot evict the baseline out from under its own check.
91
+
92
+ The match covers the existing normalized accessibility snapshot: element text,
93
+ identity, state, bounds, actionability, screen, and resolution. String identity
94
+ fields are trimmed/lowercased; native state values retain their representation.
95
+ All elements contribute, even when delta history is truncated. Pixel-only,
96
+ backend, inaccessible, and normalization-hidden changes are not detected; the
97
+ current tree does not establish foreground package/activity. Any observed
98
+ intervening change invalidates the old revision, even if the UI later returns.
99
+
100
+ A condition read can add seconds. Its timeout budget is separate from the action
101
+ budget; enclosing operation deadlines still apply. This check is not atomic with
102
+ OS input delivery. Concurrent clients, external input, or app updates can change
103
+ the UI after the observation. Sequence device actions and verify outcomes with
104
+ `expect_*` as usual.
105
+
22
106
  ## tap / swipe / type_text / press_back
23
107
 
24
108
  These tools return a shared action execution envelope.
@@ -633,7 +717,13 @@ has unknown delivery and must be observed before retrying.
633
717
 
634
718
  `tap_element` accepts exactly one `elementId` or `selector`. A selector supports
635
719
  `text`, `resource_id`, `accessibility_id`, and `contains`. Optional `waitFor` waits
636
- for the target before tapping; without it, resolution uses one observation.
720
+ for the target before tapping; without it, resolution uses one observation with
721
+ the action budget (default 5,000 ms), rather than a separate shorter cutoff.
722
+ An explicit `waitFor.timeoutMs` remains bounded by the overall action budget.
723
+ Selector taps resolve matching label text to its actionable ancestor using the
724
+ existing clickable-wait policy, including `waitFor.condition: exists` or `visible`.
725
+ Hidden/disabled labels and non-actionable containers cannot be bypassed.
726
+ Observation timeouts report `ACTION_TIMEOUT`, rather than claiming a missing target.
637
727
  Ambiguous targets require an explicit `waitFor.match.index`.
638
728
 
639
729
  `run_journey` accepts 1–50 steps with unique IDs, on one platform/device. Supported
@@ -644,5 +734,38 @@ the journey; remaining steps report `not_run`. Journey responses default to comp
644
734
  `defaults` supplies execution controls; step verification overrides the default.
645
735
  `captureOnFailure: true` includes a screenshot-free failure snapshot in debug mode.
646
736
 
647
- See [RFC 018](../specs-deltas/018-interaction-latency-and-batched-journeys.md) for
737
+ See [RFC 018](../specs/018-interaction-latency-and-batched-journeys/spec.md) for
648
738
  contracts and the device acceptance criteria.
739
+
740
+ ## Readiness-aware waits
741
+
742
+ `wait_for_ui` and `wait_for_ui_change` accept optional `requireReady: true`.
743
+ They require both their existing predicate and settled full-screen UI readiness
744
+ on the same current samples. Readiness uses a 500 ms stability window, shares the
745
+ wait's existing deadline (including retries), and adds no separate hierarchy reads.
746
+
747
+ ```json
748
+ {
749
+ "platform": "android",
750
+ "deviceId": "emulator-5554",
751
+ "selector": { "text": "Ready" },
752
+ "condition": "visible",
753
+ "requireReady": true,
754
+ "timeout_ms": 30000
755
+ }
756
+ ```
757
+
758
+ Results retain the existing success/timeout envelope and include `readiness`
759
+ evidence and limitations. Opt-in shared-deadline expiry uses a `TIMEOUT` error
760
+ code and preserves whether the underlying predicate was observed; a readiness
761
+ timeout does not mean that a visible target was absent. A visible target while a loader is active cannot pass
762
+ this opt-in check. Observation failure resets readiness continuity; an exhausted
763
+ budget is not proof of an unresponsive app. Default `requireReady: false` retains
764
+ existing predicate semantics. There is no new journey assertion or change to
765
+ conditional-action preconditions. See [app readiness](observe.md#app-readiness)
766
+ for supported states and limits.
767
+
768
+ ## Correlated evidence
769
+
770
+ Actions and journeys expose identifiers and phase timestamps for linking later
771
+ observations. See [correlated evidence](evidence.md) for scope and limitations.
@@ -10,6 +10,12 @@ These tools are primarily for:
10
10
 
11
11
  They are **not** the primary success signal when an applicable `expect_*` tool exists.
12
12
 
13
+ Covered observations support optional `actionRef` and return acquisition/freshness
14
+ metadata; see [correlated evidence](evidence.md).
15
+
16
+ Android performance investigations can use [system traces](system-traces.md)
17
+ to bracket a journey and retrieve a local Perfetto artifact.
18
+
13
19
  ## get_logs
14
20
 
15
21
  Fetch recent logs as structured entries optimized for AI agents.
@@ -91,6 +97,11 @@ Notes:
91
97
  - Elements may include a normalized `state` object when the platform exposes readable state such as checked, selected, focused, expanded, text input, or slider values.
92
98
  - Elements may also include platform-native identity hints such as `stable_id`, `role`, `test_tag`, `selector`, and `semantic`.
93
99
  - The tree response may include `snapshot_revision`, `captured_at_ms`, and `loading_state` when a reliable signal is available.
100
+ - Successful tree observations include `snapshot_session_id`, a random identifier
101
+ for this server process. Use it with `snapshot_revision` for
102
+ [conditional actions](interact.md#conditional-actions) on the same device.
103
+ Compact tree responses preserve the session ID and revision. Never derive an
104
+ expectation from an error tree, even when it carries an older revision.
94
105
  - Prefer `wait_for_ui` for deterministic element resolution in interactive flows.
95
106
  - Android reads the accessibility hierarchy with `uiautomator dump` through ADB. No helper APK is installed or required.
96
107
 
@@ -210,3 +221,62 @@ read_log_stream response example:
210
221
  ```json
211
222
  { "entries": [ { "timestamp": "2026-03-20T...Z", "level": "ERROR", "tag": "AppTag", "pid": 1234, "message": "FATAL EXCEPTION" } ], "crash_summary": { "crash_detected": true } }
212
223
  ```
224
+
225
+ ## App readiness
226
+
227
+ `get_app_readiness` observes at most two fresh hierarchies; `wait_for_app_ready`
228
+ waits for settled evidence within one budget. Require `platform`; optionally
229
+ select `deviceId`. Each call pins its device and keeps its sampling history local.
230
+
231
+ | Control | Default | Accepted integer range |
232
+ | --- | --- | --- |
233
+ | `timeoutMs` | get: 15000; wait: 30000 | 100–60000 |
234
+ | `stabilityMs` | 500 | 100–10000 |
235
+ | `pollIntervalMs` (wait only) | 500 | 100–1000 |
236
+
237
+ ```json
238
+ { "platform": "android", "deviceId": "emulator-5554", "timeoutMs": 30000 }
239
+ ```
240
+
241
+ Both return `success`, resolved `device`, `readiness`, `elapsed_ms`, and a
242
+ `failure_code` on failure. Get succeeds when it establishes loading, transitioning,
243
+ or settled; wait succeeds only for settled. Codes are `READINESS_UNAVAILABLE`,
244
+ `READINESS_TIMEOUT`, and `DEVICE_UNAVAILABLE`. Invalid/unknown inputs are rejected.
245
+
246
+ `readiness.state` is loading, transitioning, settled, unknown, or debugger_paused
247
+ for an owned Android debugger stop. Visible native
248
+ progress indicators establish loading; exact Loading / Please wait labels
249
+ (ignoring case, whitespace and trailing dots/ellipsis) are heuristic loading.
250
+ Loading takes priority over unchanged trees. Two different valid signatures show
251
+ transitioning; consecutive identical signatures spanning the stability window
252
+ show settled. Failed, empty, stale-session, wrong-device, or invalid observations
253
+ cannot establish stability and break sample continuity. Broad legacy loading
254
+ keywords such as a button named Progress are not sufficient loading evidence.
255
+
256
+ The summary includes `basis` (observed/heuristic/insufficient), `scope: device_ui`,
257
+ latest successful capture time and snapshot session/revision, sample
258
+ count, `stable_for_ms`, up to eight signal-code evidence records, limitations,
259
+ and capability flags. Evidence excludes UI text and full trees. Managed Android
260
+ [debugger pauses](debugger.md) are reported from session evidence without acquiring
261
+ a UI tree; readiness waits fail with `DEBUGGER_PAUSED`. External-debugger and iOS
262
+ pause detection remain unsupported. Unresponsive detection is unsupported; neither
263
+ slow hierarchy retrieval nor a deadline expiry proves a pause or unresponsiveness.
264
+
265
+ Tree responses (including compact and nested trees) add a single-sample readiness
266
+ summary without extra reads. It reports an owned debugger pause when known;
267
+ otherwise a single-sample summary reports loading or unknown; it does
268
+ not inherit stability from an earlier request. Existing loading/delta fields and
269
+ compact error evidence are preserved.
270
+
271
+ Readiness describes normalized foreground UI during the capture window. It does
272
+ not verify foreground app identity, process health, business outcome, or backend
273
+ completion, and does not guarantee the UI will stay unchanged. A screenshot alone
274
+ never establishes readiness. Network observation windows and device input are
275
+ untouched. Timeouts include `DEADLINE_EXCEEDED` and retain the last valid
276
+ readiness evidence; an aborted acquisition does not erase that observation.
277
+ A non-timeout observation failure still makes current state unknown. Sampling and subprocess cleanup respect outer budgets.
278
+ Transport cancellation does not currently abort platform reads immediately;
279
+ reads remain bounded by their operation deadlines.
280
+
281
+ See [readiness-aware waits](interact.md#readiness-aware-waits) to combine this
282
+ signal with a specific UI predicate.
@@ -0,0 +1,126 @@
1
+ # Android system traces
2
+
3
+ Use system traces to collect scheduling and system annotations around a slow
4
+ interaction. Capture is optional, device-wide and Android-only; it does not
5
+ automatically diagnose performance problems. No root or Android CLI installation
6
+ is required. The device must provide Perfetto (Android API 29+), authorised ADB,
7
+ tracing services, required sched/input categories, readable process identity,
8
+ base64 decoding and writable `/data/misc/perfetto-traces` storage.
9
+
10
+ ## Tools
11
+
12
+ All inputs reject unknown fields. `platform` is `android` or `ios`; iOS is
13
+ unsupported and never starts capture. Optional `deviceId` uses existing device
14
+ resolution; supplying one is recommended when multiple devices are connected.
15
+
16
+ | Tool | Arguments | Result |
17
+ | --- | --- | --- |
18
+ | `get_system_trace_capabilities` | `platform`, `deviceId?` | `supported`, `backend`, resolved `device`, prerequisite booleans, reasons, enabled sources, unavailable categories, limitations. No recording. |
19
+ | `start_system_trace` | `platform`, `deviceId?`, `durationMs?` | Owned recording session after data-source acknowledgement. Duration is an integer 1,000–120,000 ms, default 30,000. |
20
+ | `get_system_trace_status` | `traceId` | Reconcile process/deadline and finalise ended recording. |
21
+ | `stop_system_trace` | `traceId` | Gracefully stop and retrieve local artifact; retry transfer/cleanup after failure. |
22
+ | `cancel_system_trace` | `traceId` | Stop and discard partial files. Already retrieved artifacts remain local. |
23
+
24
+ Trace control IDs are UUIDs and cannot redirect to another device. One capture
25
+ per device and four pending sessions per server are allowed. Pending failed
26
+ cleanup reserves its slot until resolved. Terminal records with complete cleanup
27
+ expire after 10 minutes or eviction beyond 100 records. Server restart invalidates
28
+ IDs. Repeated terminal operations return the stored outcome.
29
+
30
+ ## Agent workflow and interpretation
31
+
32
+ Trace a reproducible slow or stalled interaction when existing UI timing/state
33
+ and logs leave a performance question unresolved. Check tool discovery first:
34
+ older installed servers may not expose these tools. Capability failure is a
35
+ reported limitation; it is not evidence that the app is frozen.
36
+
37
+ Establish the initial UI state and include an outcome assertion in the journey.
38
+ The launch-only example below demonstrates bracketing; it does not prove an app
39
+ outcome. Use a fresh screenshot or semantic assertion appropriate to the actual
40
+ screen. Settings, for example, may resume a subpage rather than show its main
41
+ title. Allow enough capture time for complete observations as well as inputs.
42
+
43
+ Always finalise an owned recording, including when a journey fails. Stop to keep
44
+ diagnostic evidence; cancel to discard it. Check both `state` and `cleanup`, and
45
+ use status/retry for an unresolved session before starting another capture.
46
+ Do not rely on automatic duration expiry as routine cleanup.
47
+
48
+ A useful report identifies the device, journey and assertion outcomes, trace ID,
49
+ linked correlation IDs, coverage, artifact path and cleanup outcome. `covered`
50
+ describes the capture session lifetime, not proof that every event survived the
51
+ ring buffer. Capture success and valid trace contents are separate from a
52
+ performance diagnosis. These tools do not return CPU summaries, frame metrics,
53
+ blocked-thread analysis or a root cause. Inspect the artifact with an available
54
+ analyser before making those claims; otherwise report analysis **unrun** and
55
+ provide the local artifact for follow-up. Host/device clocks remain unaligned.
56
+
57
+ ## Bracket a journey
58
+
59
+ 1. `get_system_trace_capabilities({"platform":"android","deviceId":"emulator-5554"})`
60
+ 2. `start_system_trace({"platform":"android","deviceId":"emulator-5554","durationMs":30000})`
61
+ 3. Use the returned `trace_id` as `systemTraceId`:
62
+
63
+ ```json
64
+ {
65
+ "platform": "android",
66
+ "deviceId": "emulator-5554",
67
+ "systemTraceId": "<trace_id>",
68
+ "steps": [
69
+ { "id": "launch", "type": "start_app", "appId": "com.android.settings", "verificationMode": "none" }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ 4. `stop_system_trace({"traceId":"<trace_id>"})` returns `artifact.path` for local
75
+ inspection in Perfetto. Delete the file/directory when finished.
76
+
77
+ An invalid, inactive, foreign-server or different-device reference prevents all
78
+ journey steps. A failed journey leaves the capture available to stop or cancel;
79
+ journeys do not control its lifetime. Without `systemTraceId`, journey behaviour
80
+ and evidence remain unchanged. Linked journey/step evidence has
81
+ `trace_context: {"supported":true,"trace_id":"…","markers":[]}`. The journey
82
+ returns `system_trace` with `trace_id`, `coverage` (`covered` or `partial` if
83
+ capture ended during execution), and `clock_alignment:"unknown"`. Trace results
84
+ list linked `correlation_ids`. These are associations, not injected markers or
85
+ proof of causality; host intervals are not aligned with device trace clocks.
86
+
87
+ ## Session response and failures
88
+
89
+ JSON responses contain `trace_id`, process-scoped `session_id`, `device`,
90
+ `backend:"perfetto"`, `state` (starting/recording/stopping/completed/cancelled/failed),
91
+ nullable `reason` and `completion_reason`, host UTC capture bounds, nullable
92
+ `artifact`, `cleanup`, `correlation_ids`, `enabled_sources`, and
93
+ `unavailable_categories`. Capture bounds include command/acknowledgement latency.
94
+
95
+ `artifact` contains absolute `path`, `format:"perfetto"` and `size_bytes`.
96
+ `cleanup` contains `status` (pending/complete/failed) and `remaining_resources`.
97
+ Errors preserve known session metadata and set MCP `isError`. Argument/unknown-ID
98
+ errors return `{error:{tool,code,message}}` without a session. Codes include
99
+ `INVALID_ARGUMENT`, `UNSUPPORTED_PLATFORM`, `TRACE_UNSUPPORTED`, `TRACE_BUSY`,
100
+ `TRACE_NOT_FOUND`, `TRACE_START_FAILED`, `TRACE_STOP_FAILED`,
101
+ `TRACE_ARTIFACT_FAILED`, `ACTION_TIMEOUT`, and `TRACE_CANCELLED`.
102
+ Capability failures return ordinary `supported:false` responses with reasons.
103
+ Read state/cleanup independently: a successful artifact transfer can still have
104
+ failed device-file cleanup. Retry stop/status, or cancel to discard failed output.
105
+
106
+ ## Limits and privacy
107
+
108
+ The fixed preset requests sched, freq, idle, am, wm, gfx, view and input categories,
109
+ a 16 MiB ring buffer and a 32 MiB maximum output. Missing optional categories are
110
+ reported; missing sched/input rejects start. No arbitrary config, wildcard app
111
+ annotations, heap, screenshot or logcat sources are enabled. Device/system
112
+ annotations may still contain sensitive information; this is not app-private data.
113
+
114
+ Capture has a device-side duration limit and a server finalisation timer. Start
115
+ acknowledgement has a 35-second budget, graceful stop 10 seconds, and transfer
116
+ 30 seconds. Only the recorded PID/start-time/output-path owner can be signalled;
117
+ no kill-by-name cleanup. A device disconnect or uncertain stop reports failure,
118
+ never a successful artifact. Partial transfers are discarded; bounded device
119
+ output remains retryable. Successful transfers remove owned device files.
120
+
121
+ Host artifacts use unique restricted temporary directories and remain for
122
+ user-managed deletion. Nothing is uploaded or returned as base64. Normal server
123
+ shutdown attempts owned cleanup for up to 10 seconds. A server crash can leave
124
+ a uniquely named bounded device file for manual removal; restart performs no
125
+ broad cleanup sweep. Device suspend can extend Perfetto's own monotonic duration
126
+ limit; the server timer bounds wall-clock lifetime while the server is running.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.32.4",
3
+ "version": "0.34.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": {
@@ -1,20 +1,62 @@
1
1
  import assert from 'node:assert/strict'
2
- import { copyFileSync, readFileSync } from 'node:fs'
2
+ import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import path from 'node:path'
5
5
 
6
6
  const repositoryRoot = fileURLToPath(new URL('../', import.meta.url))
7
- const packagedSkill = path.join(repositoryRoot, 'agent-plugin/skills/mobile-debug-mcp/SKILL.md')
8
- const sourceSkill = path.join(repositoryRoot, 'skills/mobile-debug-tools-usage/SKILL.md')
9
- const packagedLicense = path.join(repositoryRoot, 'agent-plugin/LICENSE')
7
+ const pluginRoot = path.join(repositoryRoot, 'agent-plugin')
8
+ const packagedSkill = path.join(pluginRoot, 'skills/mobile-debug-mcp/SKILL.md')
9
+ const packagedLicense = path.join(pluginRoot, 'LICENSE')
10
10
  const sourceLicense = path.join(repositoryRoot, 'LICENSE')
11
11
  const packageInfo = JSON.parse(readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8'))
12
- const plugin = JSON.parse(readFileSync(path.join(repositoryRoot, 'agent-plugin/plugin.json'), 'utf8'))
13
- const mcp = JSON.parse(readFileSync(path.join(repositoryRoot, 'agent-plugin/mcp.json'), 'utf8'))
12
+ const plugin = JSON.parse(readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8'))
13
+ const mcp = JSON.parse(readFileSync(path.join(pluginRoot, 'mcp.json'), 'utf8'))
14
+ const { $schema: _pluginSchema, extensions, ...pluginMetadata } = plugin
15
+ const { $schema: _mcpSchema, ...codexMcp } = mcp
16
+ const codexManifest = {
17
+ ...pluginMetadata,
18
+ skills: './skills/',
19
+ mcpServers: './.mcp.json',
20
+ interface: extensions['com.openai'].interface,
21
+ }
22
+ const sharedMetadata = Object.fromEntries(
23
+ ['name', 'version', 'description', 'author', 'repository', 'license'].map(key => [key, plugin[key]]),
24
+ )
25
+ const claudeManifest = {
26
+ ...sharedMetadata,
27
+ mcpServers: {
28
+ [packageInfo.name]: {
29
+ command: 'npx',
30
+ args: ['--yes', `${packageInfo.name}@${packageInfo.version}`, 'server'],
31
+ },
32
+ },
33
+ }
34
+ const cursorManifest = {
35
+ ...sharedMetadata,
36
+ skills: './skills/',
37
+ mcpServers: {
38
+ [packageInfo.name]: {
39
+ type: 'stdio',
40
+ command: 'npx',
41
+ args: ['--yes', `${packageInfo.name}@${packageInfo.version}`, 'server'],
42
+ cwd: '${CURSOR_PLUGIN_ROOT}',
43
+ },
44
+ },
45
+ }
46
+ const manifests = [
47
+ ['.codex-plugin/plugin.json', codexManifest],
48
+ ['.claude-plugin/plugin.json', claudeManifest],
49
+ ['.cursor-plugin/plugin.json', cursorManifest],
50
+ ]
14
51
 
15
52
  if (process.argv[2] === '--sync') {
16
- copyFileSync(sourceSkill, packagedSkill)
17
53
  copyFileSync(sourceLicense, packagedLicense)
54
+ writeFileSync(path.join(pluginRoot, '.mcp.json'), `${JSON.stringify(codexMcp, null, 2)}\n`)
55
+ for (const [relativePath, manifest] of manifests) {
56
+ const manifestPath = path.join(pluginRoot, relativePath)
57
+ mkdirSync(path.dirname(manifestPath), { recursive: true })
58
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
59
+ }
18
60
  }
19
61
  else if (process.argv[2] !== '--check') throw new Error('Use --sync or --check')
20
62
 
@@ -30,8 +72,26 @@ assert.equal(server.type, 'stdio')
30
72
  assert.equal(server.command, 'npx')
31
73
  assert.deepEqual(server.args, ['--yes', `${packageInfo.name}@${packageInfo.version}`, 'server'])
32
74
  assert.equal(server.cwd, '${PLUGIN_DATA}', 'npx must run outside the same-named source checkout')
33
- assert.deepEqual(readFileSync(packagedSkill), readFileSync(sourceSkill), 'packaged usage skill must match its source')
34
75
  assert.deepEqual(readFileSync(packagedLicense), readFileSync(sourceLicense), 'packaged license must match its source')
76
+ assert.deepEqual(JSON.parse(readFileSync(path.join(pluginRoot, '.mcp.json'), 'utf8')), codexMcp, 'Codex MCP configuration must match the portable plugin')
77
+ for (const [relativePath, expectedManifest] of manifests) {
78
+ assert.deepEqual(
79
+ JSON.parse(readFileSync(path.join(pluginRoot, relativePath), 'utf8')),
80
+ expectedManifest,
81
+ `${relativePath} must match the portable plugin`,
82
+ )
83
+ }
84
+ for (const marketplacePath of [
85
+ '.agents/plugins/marketplace.json',
86
+ '.claude-plugin/marketplace.json',
87
+ '.cursor-plugin/marketplace.json',
88
+ ]) {
89
+ const marketplace = JSON.parse(readFileSync(path.join(repositoryRoot, marketplacePath), 'utf8'))
90
+ assert.equal(marketplace.name, plugin.name, `${marketplacePath} name must match the plugin`)
91
+ assert.equal(marketplace.plugins.length, 1, `${marketplacePath} must expose one plugin`)
92
+ assert.equal(marketplace.plugins[0].name, plugin.name, `${marketplacePath} plugin name must match`)
93
+ assert.equal(marketplace.plugins[0].source?.path ?? marketplace.plugins[0].source, './agent-plugin', `${marketplacePath} source must point to the plugin`)
94
+ }
35
95
  const skillName = readFileSync(packagedSkill, 'utf8').match(/^---\r?\nname: ([a-z0-9-]+)\r?\n/)?.[1]
36
96
  assert.equal(skillName, path.basename(path.dirname(packagedSkill)), 'packaged skill directory must match its frontmatter name')
37
97
 
@@ -0,0 +1,65 @@
1
+ import { resolveAdbCmd } from '../utils/android/utils.js'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import { DebuggerError, debuggerRemaining, debuggerSignal, nativeError } from './worker.js'
5
+ const exec = promisify(execFile)
6
+ export interface DebugTarget { app_id: string; pid: number; process_name: string; start_identity: string; uid: number }
7
+ export interface DebugNative {
8
+ candidates(device: string, app: string): Promise<DebugTarget[]>
9
+ identity(device: string, target: DebugTarget): Promise<DebugTarget>
10
+ forward(device: string, pid: number): Promise<number>
11
+ removeForward(device: string, port: number, pid: number): Promise<void>
12
+ }
13
+ export class AndroidDebugNative implements DebugNative {
14
+ async adb(device: string, args: string[]) {
15
+ try { return (await exec(resolveAdbCmd(), ['-s', device, ...args], { timeout: debuggerRemaining(5000), signal: debuggerSignal(), maxBuffer: 1024 * 1024 })).stdout.trim() }
16
+ catch (e) { throw nativeError(e, 'DEVICE_UNAVAILABLE') }
17
+ }
18
+ async identity(device: string, target: DebugTarget): Promise<DebugTarget> {
19
+ try {
20
+ const stat = await this.adb(device, ['shell', 'cat', `/proc/${target.pid}/stat`])
21
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/)
22
+ const status = await this.adb(device, ['shell', 'cat', `/proc/${target.pid}/status`])
23
+ const uid = Number(/^Uid:\s+(\d+)/m.exec(status)?.[1])
24
+ const name = (await this.adb(device, ['shell', 'cat', `/proc/${target.pid}/cmdline`])).split('\0')[0]
25
+ if (!/^\d+$/.test(fields[19] ?? '') || uid !== target.uid || name !== target.process_name || (target.start_identity && fields[19] !== target.start_identity)) throw new Error('identity')
26
+ return { ...target, start_identity: fields[19] }
27
+ } catch (e) { if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED', 'DEVICE_UNAVAILABLE'].includes(e.code)) throw e; throw new DebuggerError('PROCESS_CHANGED') }
28
+ }
29
+ async candidates(device: string, app: string) {
30
+ if (!/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/.test(app)) throw new DebuggerError('INVALID_ARGUMENT')
31
+ const packages = await this.adb(device, ['shell', 'cmd', 'package', 'list', 'packages', '-U', app])
32
+ const uid = Number(new RegExp(`^package:${app.replaceAll('.', '\\.')} uid:(\\d+)$`, 'm').exec(packages)?.[1])
33
+ if (!Number.isSafeInteger(uid)) throw new DebuggerError('PROCESS_NOT_FOUND')
34
+ // `adb jdwp` is a subscription, not a one-shot command. Bound the sample.
35
+ let jdwpOutput: string
36
+ try { jdwpOutput = (await exec(resolveAdbCmd(), ['-s', device, 'jdwp'], { timeout: debuggerRemaining(500), signal: debuggerSignal(), maxBuffer: 1024 * 1024 })).stdout }
37
+ catch (e: any) {
38
+ if (debuggerSignal()?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
39
+ debuggerRemaining(1)
40
+ if (!e.killed || e.stderr?.trim()) throw nativeError(e, 'DEVICE_UNAVAILABLE')
41
+ jdwpOutput = e.stdout ?? ''
42
+ }
43
+ const jdwp = new Set(jdwpOutput.split(/\s+/).filter(id => /^\d+$/.test(id)))
44
+ const ps = await this.adb(device, ['shell', 'ps', '-A', '-o', 'PID,NAME'])
45
+ const result: DebugTarget[] = []
46
+ for (const line of ps.split('\n').slice(1)) {
47
+ const [pid, name] = line.trim().split(/\s+/)
48
+ if (jdwp.has(pid) && (name === app || name.startsWith(app + ':'))) result.push(await this.identity(device, { app_id: app, pid: Number(pid), process_name: name, start_identity: '', uid }))
49
+ }
50
+ return result
51
+ }
52
+ async forward(device: string, pid: number) {
53
+ const port = Number(await this.adb(device, ['forward', '--no-rebind', 'tcp:0', `jdwp:${pid}`]))
54
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new DebuggerError('DEBUGGER_ATTACH_FAILED')
55
+ return port
56
+ }
57
+ async removeForward(device: string, port: number, pid: number) {
58
+ const list = await this.adb(device, ['forward', '--list'])
59
+ const entry = list.split('\n').find(line => line.trim().split(/\s+/)[1] === `tcp:${port}`)
60
+ if (!entry) return
61
+ const [serial, local, remote] = entry.trim().split(/\s+/)
62
+ if (serial !== device || local !== `tcp:${port}` || remote !== `jdwp:${pid}`) throw new DebuggerError('DEBUGGER_CLEANUP_FAILED')
63
+ await this.adb(device, ['forward', '--remove', `tcp:${port}`])
64
+ }
65
+ }
@@ -0,0 +1,26 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { DebuggerError } from './worker.js'
3
+ let pauseProvider: (device: string) => any = () => null
4
+ export function registerDebuggerPauseProvider(provider: typeof pauseProvider) { pauseProvider = provider }
5
+ const context = new AsyncLocalStorage<{ platform: string; device: string; dispatched: boolean }>()
6
+ export function withDebuggerGuard<T>(platform: string, device: string, operation: () => Promise<T>) {
7
+ return context.run({ platform, device, dispatched: false }, operation)
8
+ }
9
+ // Synchronous checks and native submission share a JS turn with pause updates.
10
+ export function checkDebuggerGuard(dispatch = false) {
11
+ const current = context.getStore()
12
+ if (!current || current.platform !== 'android') return
13
+ if (pauseProvider(current.device)) throw new DebuggerError('DEBUGGER_PAUSED')
14
+ if (dispatch) current.dispatched = true
15
+ }
16
+ export function guardDispatched() { return context.getStore()?.dispatched ?? false }
17
+ export function pausedReadiness(device: string) {
18
+ const paused = pauseProvider(device)
19
+ if (!paused) return null
20
+ return { state: 'debugger_paused' as const, scope: 'device_ui' as const, basis: 'observed' as const,
21
+ captured_at_ms: paused.captured_at_ms, sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
22
+ evidence: [{ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms, snapshot_revision: null }],
23
+ limitations: ['FOREGROUND_APP_UNVERIFIED', 'DEVICE_WIDE_UI_GUARD'],
24
+ capabilities: { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: true },
25
+ debug_session_id: paused.debug_session_id, target: paused.target }
26
+ }