mobile-debug-mcp 0.35.0 → 0.37.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 (247) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +96 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
  19. package/dist/contracts/device-readiness.js +1 -0
  20. package/dist/debugger/guard.js +4 -4
  21. package/dist/debugger/ios-worker.js +62 -0
  22. package/dist/debugger/ios.js +57 -0
  23. package/dist/debugger/sessions.js +33 -16
  24. package/dist/debugger/worker.js +6 -4
  25. package/dist/interact/index.js +12 -2
  26. package/dist/interact/waits/ui-change-wait.js +9 -2
  27. package/dist/interact/waits/ui-wait.js +7 -3
  28. package/dist/manage/index.js +3 -0
  29. package/dist/manage/ios.js +2 -0
  30. package/dist/observe/device-readiness.js +116 -0
  31. package/dist/observe/evidence.js +31 -0
  32. package/dist/observe/ios-trace/artifact.js +63 -0
  33. package/dist/observe/ios-trace/capabilities.js +86 -0
  34. package/dist/observe/ios-trace/native.js +93 -0
  35. package/dist/observe/ios-trace/notification.js +35 -0
  36. package/dist/observe/ios-trace/process.js +98 -0
  37. package/dist/observe/ios-trace/sessions.js +246 -0
  38. package/dist/observe/readiness-service.js +22 -10
  39. package/dist/observe/readiness.js +34 -4
  40. package/dist/observe/screenshot-encoding.js +4 -0
  41. package/dist/observe/snapshot-collection.js +7 -2
  42. package/dist/observe/system-trace-error.js +7 -0
  43. package/dist/observe/system-trace-router.js +81 -0
  44. package/dist/observe/system-trace.js +4 -8
  45. package/dist/server/definitions/manage.js +3 -2
  46. package/dist/server/handlers/action-execution.js +23 -14
  47. package/dist/server/handlers/compact-interaction.js +106 -0
  48. package/dist/server/handlers/compact-state.js +47 -0
  49. package/dist/server/handlers/debugger-handlers.js +27 -8
  50. package/dist/server/handlers/diagnostic.js +12 -4
  51. package/dist/server/handlers/input.js +2 -0
  52. package/dist/server/handlers/interact.js +2 -0
  53. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  54. package/dist/server/handlers/journey.js +18 -20
  55. package/dist/server/handlers/observe.js +2 -1
  56. package/dist/server/handlers/tool-handlers.js +28 -14
  57. package/dist/server/schemas/debugger-schema.js +10 -7
  58. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  59. package/dist/server/schemas/system-trace-schema.js +14 -9
  60. package/dist/server/tool-definitions.js +9 -4
  61. package/dist/server-core.js +5 -2
  62. package/dist/server.js +5 -5
  63. package/dist/system/android.js +19 -15
  64. package/dist/system/index.js +2 -1
  65. package/dist/utils/android/utils.js +7 -16
  66. package/dist/utils/app-installation.js +77 -0
  67. package/dist/utils/benchmark-metrics.js +43 -0
  68. package/dist/utils/exec.js +4 -0
  69. package/dist/utils/ios/utils.js +7 -11
  70. package/dist/utils/resolve-device.js +3 -2
  71. package/dist/utils/timing.js +3 -3
  72. package/docs/CHANGELOG.md +48 -1
  73. package/docs/agent-plugin-installation-verification.md +39 -0
  74. package/docs/benchmarks/README.md +1 -0
  75. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  76. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  77. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  78. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  79. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  80. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  81. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  82. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  83. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  84. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  85. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  86. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  87. package/docs/code-structure.md +3 -1
  88. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  89. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  90. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  91. package/docs/specs/031-ios-system-traces/plan.md +166 -0
  92. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  93. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  94. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  95. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  96. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  97. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  98. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  99. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  100. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  101. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  102. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  103. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  104. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  105. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  106. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  107. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  108. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  109. package/docs/specs/034-app-installation-status/intent.md +41 -0
  110. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  111. package/docs/specs/034-app-installation-status/plan.md +73 -0
  112. package/docs/specs/034-app-installation-status/spec.md +114 -0
  113. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  114. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  115. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  116. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  117. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  118. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  119. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  120. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  121. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  122. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  123. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  124. package/docs/tools/TOOLS.md +3 -2
  125. package/docs/tools/compact-interactions.md +56 -0
  126. package/docs/tools/correlated-evidence.md +6 -0
  127. package/docs/tools/debugger.md +45 -5
  128. package/docs/tools/interact.md +17 -8
  129. package/docs/tools/manage.md +10 -2
  130. package/docs/tools/observe.md +42 -2
  131. package/docs/tools/system-traces.md +81 -14
  132. package/docs/tools/system.md +6 -0
  133. package/package.json +2 -1
  134. package/scripts/agent-plugin.mjs +55 -5
  135. package/src/contracts/device-readiness.ts +8 -0
  136. package/src/debugger/android.ts +1 -1
  137. package/src/debugger/guard.ts +5 -5
  138. package/src/debugger/ios-worker.ts +49 -0
  139. package/src/debugger/ios.ts +45 -0
  140. package/src/debugger/lldb/breakpoints.py +51 -0
  141. package/src/debugger/lldb/inspection.py +109 -0
  142. package/src/debugger/lldb/worker.py +203 -0
  143. package/src/debugger/sessions.ts +24 -12
  144. package/src/debugger/worker.ts +7 -4
  145. package/src/interact/index.ts +13 -2
  146. package/src/interact/waits/ui-change-wait.ts +7 -2
  147. package/src/interact/waits/ui-wait.ts +6 -3
  148. package/src/manage/index.ts +2 -0
  149. package/src/manage/ios.ts +2 -0
  150. package/src/observe/device-readiness.ts +96 -0
  151. package/src/observe/evidence.ts +30 -1
  152. package/src/observe/ios-trace/artifact.ts +55 -0
  153. package/src/observe/ios-trace/capabilities.ts +74 -0
  154. package/src/observe/ios-trace/native.ts +72 -0
  155. package/src/observe/ios-trace/notification.ts +30 -0
  156. package/src/observe/ios-trace/process.ts +82 -0
  157. package/src/observe/ios-trace/sessions.ts +206 -0
  158. package/src/observe/readiness-service.ts +22 -10
  159. package/src/observe/readiness.ts +37 -4
  160. package/src/observe/screenshot-encoding.ts +4 -0
  161. package/src/observe/snapshot-collection.ts +7 -2
  162. package/src/observe/system-trace-error.ts +3 -0
  163. package/src/observe/system-trace-router.ts +61 -0
  164. package/src/observe/system-trace.ts +4 -4
  165. package/src/server/common.ts +2 -2
  166. package/src/server/definitions/manage.ts +3 -2
  167. package/src/server/handlers/action-execution.ts +22 -14
  168. package/src/server/handlers/compact-interaction.ts +91 -0
  169. package/src/server/handlers/compact-state.ts +43 -0
  170. package/src/server/handlers/debugger-handlers.ts +28 -9
  171. package/src/server/handlers/diagnostic.ts +11 -4
  172. package/src/server/handlers/input.ts +2 -0
  173. package/src/server/handlers/interact.ts +2 -0
  174. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  175. package/src/server/handlers/journey.ts +15 -17
  176. package/src/server/handlers/observe.ts +2 -1
  177. package/src/server/handlers/payload.ts +2 -1
  178. package/src/server/handlers/tool-handlers.ts +29 -14
  179. package/src/server/schemas/debugger-schema.ts +10 -7
  180. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  181. package/src/server/schemas/system-trace-schema.ts +15 -9
  182. package/src/server/tool-definitions.ts +8 -4
  183. package/src/server-core.ts +5 -2
  184. package/src/server.ts +4 -4
  185. package/src/system/android.ts +18 -11
  186. package/src/system/index.ts +2 -1
  187. package/src/types.ts +2 -0
  188. package/src/utils/android/utils.ts +8 -10
  189. package/src/utils/app-installation.ts +73 -0
  190. package/src/utils/benchmark-metrics.ts +42 -0
  191. package/src/utils/exec.ts +5 -0
  192. package/src/utils/ios/utils.ts +7 -10
  193. package/src/utils/resolve-device.ts +4 -2
  194. package/src/utils/timing.ts +3 -2
  195. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  196. package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
  197. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  198. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  199. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  200. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  201. package/test/device/automated/latency/end-to-end.ts +149 -0
  202. package/test/device/automated/latency/owned-transport.ts +81 -0
  203. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  204. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  205. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  206. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  207. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  208. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  209. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  210. package/test/fixtures/contracts/tool-definitions.json +243 -47
  211. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  212. package/test/fixtures/ios-debugger-app/README.md +27 -0
  213. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  214. package/test/fixtures/latency-journey-app/build.ts +3 -3
  215. package/test/helpers/ios-trace-shutdown.ts +29 -0
  216. package/test/unit/debugger/handle-guards.test.ts +89 -0
  217. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  218. package/test/unit/debugger/ios.test.ts +104 -0
  219. package/test/unit/debugger/lldb-worker.py +166 -0
  220. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  221. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  222. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  223. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  224. package/test/unit/observe/device-readiness.test.ts +97 -0
  225. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  226. package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
  227. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  228. package/test/unit/observe/readiness.test.ts +1 -1
  229. package/test/unit/observe/system-trace-router.test.ts +49 -0
  230. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  231. package/test/unit/server/compact-interaction.test.ts +87 -0
  232. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  233. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  234. package/test/unit/server/conditional_actions.test.ts +7 -3
  235. package/test/unit/server/correlated-evidence.test.ts +18 -6
  236. package/test/unit/server/end-to-end-report.test.ts +46 -0
  237. package/test/unit/server/input-device-readiness.test.ts +55 -0
  238. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  239. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  240. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  241. package/test/unit/server/readiness.test.ts +24 -1
  242. package/test/unit/server/run_journey.test.ts +2 -1
  243. package/test/unit/server/selector_tap_target.test.ts +2 -1
  244. package/test/unit/server/system-trace.test.ts +18 -7
  245. package/test/unit/utils/app-installation.test.ts +78 -0
  246. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  247. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { evidenceSessionId } from '../../observe/evidence.js'
3
+ import { diagnosticsSchema } from '../schemas/interaction-diagnostics.js'
4
+ import { wrapResponse, type ToolCallArgs } from '../common.js'
5
+
6
+ type ExpiryScheduler = (expire: () => void) => () => void
7
+ const scheduleExpiry: ExpiryScheduler = expire => {
8
+ const timer = setTimeout(expire, 600000)
9
+ timer.unref()
10
+ return () => clearTimeout(timer)
11
+ }
12
+
13
+ export class InteractionDiagnostics {
14
+ private readonly entries = new Map<string, { text: string; completed: number; bytes: number; cancel: () => void }>()
15
+ private bytes = 0
16
+ constructor(private readonly now = () => performance.now(), readonly sessionId = evidenceSessionId(), private readonly schedule: ExpiryScheduler = scheduleExpiry) {}
17
+
18
+ private remove(id: string) {
19
+ const entry = this.entries.get(id)
20
+ if (entry) { this.bytes -= entry.bytes; entry.cancel() }
21
+ this.entries.delete(id)
22
+ }
23
+ private prune() {
24
+ for (const [id, entry] of this.entries) if (this.now() - entry.completed >= 600000) this.remove(id)
25
+ }
26
+ retain(result: unknown) {
27
+ this.prune()
28
+ const resultId = randomUUID(), text = JSON.stringify(result), bytes = Buffer.byteLength(text)
29
+ const ref = { session_id: this.sessionId, result_id: resultId }
30
+ if (bytes > 1024 * 1024) return { ...ref, available: false, reason: 'RESULT_TOO_LARGE' }
31
+ while (this.entries.size >= 100 || this.bytes + bytes > 16 * 1024 * 1024) this.remove(this.entries.keys().next().value!)
32
+ this.entries.set(resultId, { text, completed: this.now(), bytes, cancel: this.schedule(() => this.remove(resultId)) })
33
+ this.bytes += bytes
34
+ return { ...ref, available: true }
35
+ }
36
+ lookup(sessionId: string, resultId: string) {
37
+ this.prune()
38
+ if (sessionId !== this.sessionId) return { success: false, error: { code: 'SESSION_MISMATCH', message: 'Use the session_id returned with this result.' } }
39
+ const entry = this.entries.get(resultId)
40
+ if (!entry) return { success: false, error: { code: 'RESULT_UNAVAILABLE', message: 'Result expired, was evicted, is unknown, or belongs to a previous server process. Do not replay input automatically.' } }
41
+ return { success: true, result: JSON.parse(entry.text) as unknown }
42
+ }
43
+ }
44
+ export const interactionDiagnostics = new InteractionDiagnostics()
45
+ export async function handleInteractionDiagnostics(args: ToolCallArgs) {
46
+ const parsed = diagnosticsSchema.safeParse(args)
47
+ return wrapResponse(parsed.success ? interactionDiagnostics.lookup(parsed.data.sessionId, parsed.data.resultId) : {
48
+ success: false, error: { code: 'INVALID_ARGUMENT', message: 'Expected only sessionId and resultId UUID strings.' }
49
+ }, false)
50
+ }
@@ -1,7 +1,9 @@
1
+ import { observeDeviceReadiness } from '../../observe/device-readiness.js'
2
+ import { benchmarkStage } from '../../utils/benchmark-metrics.js'
1
3
  import type { z } from 'zod'
2
4
  import { debuggerSessions } from '../../debugger/sessions.js'
3
5
  import { systemTraces } from '../../observe/system-trace.js'
4
- import { currentEvidenceCorrelationId, bindSystemTrace, refreshJourneyEvidence, completeJourneyVerification } from '../../observe/evidence.js'
6
+ import { currentEvidenceCorrelationId, bindSystemTrace, withJourneyStepEvidence, refreshJourneyEvidence, completeJourneyVerification, JourneyStepError } from '../../observe/evidence.js'
5
7
  import { runWithBudget } from '../../utils/operation-budget.js'
6
8
  import { journeySchema } from '../schemas/journey-schema.js'
7
9
  import { resolveTargetDevice, withResolvedDevice } from '../../utils/resolve-device.js'
@@ -33,19 +35,22 @@ async function assertStep(step: AssertionStep, platform: Journey['platform'], de
33
35
  if (assertion.kind === 'state_equals') return ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: timeoutMs, poll_interval_ms: pollIntervalMs, exact_budget: true })
34
36
  return { success: false, failure_code: 'INVALID_ASSERTION' }
35
37
  }
38
+ async function observeStep(s: Context, operation: () => Promise<ToolPayload>) {
39
+ return s.input.responseMode === 'compact' ? withJourneyStepEvidence(operation) : operation()
40
+ }
36
41
  async function executeStep(s: Context, step: Step, covered: boolean): Promise<ToolPayload> {
37
42
  const { platform, responseMode, defaults } = s.input, deviceId = s.deviceId
38
43
  try {
39
44
  if (step.type === 'tap') return payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: covered ? 'none' : step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
40
- if (step.type === 'wait') {
45
+ if (step.type === 'wait') return await observeStep(s, async () => {
41
46
  const result = (await withObservationContext({ stepId: step.id, purpose: 'target' }, () => ToolsInteract.waitForUIHandler({ selector: step.selector, condition: step.condition ?? 'exists', timeout_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, platform, deviceId })))!
42
47
  return { success: result.status === 'success', ...result }
43
- }
44
- if (step.type === 'assert') return await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget(step.timeoutMs ?? 5000, () => assertStep(step, platform, deviceId)))
48
+ })
49
+ if (step.type === 'assert') return await observeStep(s, () => withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget(step.timeoutMs ?? 5000, () => assertStep(step, platform, deviceId))))
45
50
  if (step.type === 'start_app') return payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
46
51
  return { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
47
52
  } catch (error) {
48
- return { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
53
+ return { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error), ...(error instanceof JourneyStepError ? { evidence: error.evidence } : {}) }
49
54
  }
50
55
  }
51
56
  function lightVerification(s: Context, step: Step, result: ToolPayload, covered: boolean, success: boolean) {
@@ -68,24 +73,16 @@ function lightVerification(s: Context, step: Step, result: ToolPayload, covered:
68
73
  }
69
74
  s.pendingLightStep = null
70
75
  }
71
- function compactAssertion(result: ToolPayload, success: boolean) {
72
- const errorCode = typeof result.error === 'object' ? result.error?.code : undefined
73
- return { success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
74
- ...(errorCode ? { failure_code: errorCode } : {}),
75
- ...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
76
- ...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
77
- ...(result.actual_state ? { observed: result.actual_state } : {}) }
78
- }
79
76
  function coveredByAssertion(s: Context, step: Step, next?: Step) {
80
77
  const verification = ('verificationMode' in step ? step.verificationMode : undefined) ?? s.input.defaults?.verificationMode ?? 'full'
81
78
  return step.type === 'tap' && s.input.responseMode === 'compact' && verification === 'light' && next?.type === 'assert' && next.assertion.kind === 'element_visible'
82
79
  }
83
80
  async function runSteps(s: Context) {
84
- const { platform, responseMode, steps } = s.input
81
+ const { platform, steps } = s.input
85
82
  for (let index = 0; index < steps.length; index++) {
86
83
  const step = steps[index]
87
84
  if (platform === 'android' && debuggerSessions.paused(s.deviceId)) {
88
- s.results.push({ id: step.id, type: step.type, status: 'failed', success: false, result: { success: false, failure_code: 'DEBUGGER_PAUSED', dispatch_started: false, delivery_status: 'not_delivered' } })
85
+ s.results.push({ id: step.id, type: step.type, status: 'failed', success: false, result: { device_readiness: await observeDeviceReadiness({ platform, id: s.deviceId }), success: false, failure_code: 'DEBUGGER_PAUSED', dispatch_started: false, delivery_status: 'not_delivered' } })
89
86
  s.stoppedAt = step.id
90
87
  break
91
88
  }
@@ -94,12 +91,13 @@ async function runSteps(s: Context) {
94
91
  s.results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } })
95
92
  continue
96
93
  }
97
- let result = await executeStep(s, step, covered)
94
+ const result = await (step.type === 'assert' || step.type === 'wait'
95
+ ? benchmarkStage(step.type === 'assert' ? 'assertion_ms' : 'wait_ms', () => executeStep(s, step, covered))
96
+ : executeStep(s, step, covered))
98
97
  if (platform === 'android' && debuggerSessions.paused(s.deviceId)) { result.success = false; result.failure_code = 'DEBUGGER_PAUSED'; result.retryable = false }
99
98
  if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
100
99
  const success = !!result?.success
101
100
  lightVerification(s, step, result, covered, success)
102
- if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type)) result = compactAssertion(result, success)
103
101
  s.results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - started } })
104
102
  if (!success) s.stoppedAt = step.id
105
103
  }
@@ -1,6 +1,7 @@
1
1
 
2
2
 
3
3
  import { ToolsObserve } from '../../observe/index.js'
4
+ import { benchmarkStage } from '../../utils/benchmark-metrics.js'
4
5
  import { withObservationContext } from '../../observe/performance-metrics.js'
5
6
  import {
6
7
  getBooleanArg,
@@ -37,7 +38,7 @@ export async function handleCaptureScreenshot(args: ToolCallArgs) {
37
38
  const platform = requireStringArg(args, 'platform') as PlatformArg
38
39
  const deviceId = getStringArg(args, 'deviceId')
39
40
  const started = performance.now()
40
- const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
41
+ const res = await benchmarkStage('screenshot_capture_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId }))
41
42
  const screenshotMs = performance.now() - started
42
43
  const mime = res.screenshot_mime || 'image/png'
43
44
  const content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }> = [
@@ -4,6 +4,7 @@ import type { ToolCallResult } from '../common.js'
4
4
  // Internal decoded MCP payloads vary by capability. Keep the fields inspected by
5
5
  // orchestration explicit while preserving untouched capability-specific fields.
6
6
  export interface ToolPayload {
7
+ device_readiness?: import('../../contracts/device-readiness.js').DeviceReadiness
7
8
  success?: boolean
8
9
  action_id?: string
9
10
  action_type?: string
@@ -25,7 +26,7 @@ export interface ToolPayload {
25
26
  status?: string
26
27
  condition?: ActionConditionResult
27
28
  verification?: { mode?: string; status: string }
28
- evidence?: { action_id?: string }
29
+ evidence?: { action_id?: string | null }
29
30
  verification_diagnostic?: { code: string; retryable: boolean }
30
31
  observed?: { matched_count?: number | null }
31
32
  timing?: TimingBreakdown
@@ -1,7 +1,11 @@
1
+ import { finalizeCompactInteraction } from './compact-interaction.js'
2
+ import { handleInteractionDiagnostics } from './interaction-diagnostics.js'
3
+ import { observeDeviceReadiness } from '../../observe/device-readiness.js'
4
+ import { ToolsInteract } from '../../interact/index.js'
1
5
  import type { DeviceInfo } from '../../types.js'
2
6
  import { handleDebugger } from './debugger-handlers.js'
3
7
  import { debuggerContracts } from '../schemas/debugger-schema.js'
4
- import { debuggerSessions } from '../../debugger/sessions.js'
8
+ import { debuggerSessions, iosDebuggerSessions } from '../../debugger/sessions.js'
5
9
  import { withDebuggerGuard, guardDispatched } from '../../debugger/guard.js'
6
10
  import { TraceError } from '../../observe/system-trace.js'
7
11
  import { systemTraceTools } from '../schemas/system-trace-schema.js'
@@ -40,6 +44,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
40
44
  if (!parsed.success) throw new Error('INVALID_ARGUMENT: invalid readiness arguments')
41
45
  return wrapResponse(await getAppReadiness(parsed.data, true))
42
46
  },
47
+ get_interaction_diagnostics: handleInteractionDiagnostics,
43
48
  start_app: handleStartApp,
44
49
  terminate_app: handleTerminateApp,
45
50
  restart_app: handleRestartApp,
@@ -81,23 +86,32 @@ for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint
81
86
  toolHandlers[name] = correlatedHandler(name, toolHandlers[name], name === 'adjust_control', name === 'run_journey')
82
87
  }
83
88
 
89
+ function resolveGuardDevice(name: string, args: ToolCallArgs) {
90
+ const platform = args.platform as 'android' | 'ios' | undefined
91
+ const deviceId = args.deviceId as string | undefined
92
+ const stored = name === 'tap_element' && typeof args.elementId === 'string'
93
+ ? ToolsInteract.getResolvedElementTarget(args.elementId, platform, deviceId) : null
94
+ return resolveTargetDevice(stored ?? { platform: platform === 'ios' ? 'ios' : 'android', deviceId })
95
+ }
96
+
84
97
  async function dispatchGuarded(device: DeviceInfo, args: ToolCallArgs, handler: ToolHandler, addTiming: (response: ToolCallResult) => ToolCallResult) {
85
- return withResolvedDevice(device, () => withDebuggerGuard('android', device.id, async () => {
86
- const before = debuggerSessions.paused(device.id)
87
- if (before) return wrapResponse({ success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before })
98
+ const sessions = device.platform === 'ios' ? iosDebuggerSessions : debuggerSessions
99
+ return withResolvedDevice(device, () => withDebuggerGuard(device.platform, device.id, async () => {
100
+ const before = sessions.paused(device.id)
101
+ if (before) return addTiming(wrapResponse({ device_readiness: await observeDeviceReadiness(device), success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before }))
88
102
  let response: ToolCallResult
89
103
  try { response = await handler(args) } catch (error) {
90
- if (!debuggerSessions.paused(device.id)) throw error
104
+ if (!sessions.paused(device.id)) throw error
91
105
  response = wrapResponse({ success: false })
92
106
  }
93
- const pause = debuggerSessions.paused(device.id)
107
+ const pause = sessions.paused(device.id)
94
108
  if (pause) {
95
109
  const item = response.content.find(item => item.type === 'text')
96
110
  if (item?.text) {
97
111
  const payload = JSON.parse(item.text)
98
112
  Object.assign(payload, { success: false, failure_code: 'DEBUGGER_PAUSED', retryable: false,
99
113
  dispatch_started: guardDispatched(), delivery_status: payload.success ? 'delivered' : guardDispatched() ? 'unknown' : 'not_delivered',
100
- verification: { status: 'blocked', reason: 'DEBUGGER_PAUSED' }, debugger: pause })
114
+ verification: { status: 'blocked', reason: 'DEBUGGER_PAUSED' }, device_readiness: await observeDeviceReadiness(device), debugger: pause })
101
115
  item.text = JSON.stringify(payload)
102
116
  }
103
117
  }
@@ -106,14 +120,15 @@ async function dispatchGuarded(device: DeviceInfo, args: ToolCallArgs, handler:
106
120
  }
107
121
 
108
122
  function attachPausedObservation(name: string, args: ToolCallArgs, response: ToolCallResult) {
109
- if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name) && args.platform !== 'ios') {
123
+ if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name)) {
110
124
  const item = response.content.find(item => item.type === 'text')
111
125
  if (item?.text) {
112
126
  try {
113
127
  const payload = JSON.parse(item.text)
114
128
  const device = payload.device ?? payload.evidence?.device
115
129
  const id = device?.id ?? args.deviceId
116
- const pause = id && device?.platform !== 'ios' ? debuggerSessions.paused(String(id)) : null
130
+ const sessions = (device?.platform ?? args.platform) === 'ios' ? iosDebuggerSessions : debuggerSessions
131
+ const pause = id ? sessions.paused(String(id)) : null
117
132
  if (pause) item.text = JSON.stringify({ ...payload, debugger: pause })
118
133
  } catch { /* Non-JSON observation */ }
119
134
  }
@@ -128,7 +143,7 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}, sign
128
143
  const started = performance.now()
129
144
  const measured: Record<string, number> = {}
130
145
  const addTiming = (response: ToolCallResult) => {
131
- if (!['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'wait_for_ui', 'get_ui_tree', 'capture_screenshot', 'capture_debug_snapshot'].includes(name)) return response
146
+ if (!['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'wait_for_ui', 'get_ui_tree', 'capture_screenshot', 'capture_debug_snapshot'].includes(name)) return finalizeCompactInteraction(name, args, response)
132
147
  const item = response.content.find(item => item.type === 'text')
133
148
  if (item?.text) {
134
149
  try {
@@ -137,21 +152,21 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}, sign
137
152
  item.text = JSON.stringify(value)
138
153
  } catch { /* Non-JSON text responses retain their original content. */ }
139
154
  }
140
- return response
155
+ return finalizeCompactInteraction(name, args, response)
141
156
  }
142
157
  return withTiming(measured, async () => {
143
158
  try {
144
159
  if (!systemTraceToolNames.has(name)) validateExpectedState(name, args)
145
160
  const guarded = ['start_app', 'terminate_app', 'restart_app', 'reset_app_data', 'install_app', 'build_and_install', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'adjust_control', 'run_journey', 'wait_for_ui', 'wait_for_ui_change', 'wait_for_screen_change', 'expect_screen', 'expect_element_visible', 'expect_state'].includes(name)
146
- if (guarded && args.platform !== 'ios' && debuggerSessions.hasActiveSessions()) {
147
- const device = await resolveTargetDevice({ platform: 'android', deviceId: args.deviceId as string | undefined })
161
+ if (guarded && (debuggerSessions.hasActiveSessions() || iosDebuggerSessions.hasActiveSessions())) {
162
+ const device = await resolveGuardDevice(name, args)
148
163
  return dispatchGuarded(device, args, handler, addTiming)
149
164
  }
150
165
  const response = await handler(args)
151
166
  attachPausedObservation(name, args, response)
152
167
  return addTiming(response)
153
168
  } catch (error) {
154
- if (error instanceof TraceError) return { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true }
169
+ if (error instanceof TraceError) return addTiming({ ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true })
155
170
  console.error(`Error executing tool ${name}:`, error)
156
171
  return addTiming(wrapToolError(name, error))
157
172
  }
@@ -2,21 +2,22 @@ import { z } from 'zod'
2
2
  const text = z.string().min(1).max(512)
3
3
  const integer = z.number().int().safe().positive()
4
4
  const timeout = z.number().int().min(100).max(30000)
5
- const target = { platform: z.literal('android'), deviceId: text, appId: text.regex(/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/), processId: integer.optional() }
5
+ const iosUdid = /^[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$/
6
+ const target = { platform: z.enum(['android', 'ios']), deviceId: text, appId: text.regex(/^[A-Za-z][\w-]*(?:\.[A-Za-z][\w-]*)+$/), processId: integer.optional() }
6
7
  const session = { debugSessionId: text }
7
8
  const stop = { ...session, stopId: text }
8
9
  const specs = {
9
- get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional Android JDK/JDWP prerequisites and matching running debuggable processes; does not attach.' },
10
- attach_debugger: { fields: target, command: 'attach', description: 'Attach an owned Android JDI session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.' },
10
+ get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional platform debugger prerequisites and matching running debuggable processes; does not attach.' },
11
+ attach_debugger: { fields: target, command: 'attach', description: 'Attach an owned Android JDI or iOS simulator LLDB session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.' },
11
12
  get_debugger_state: { fields: session, command: 'state', description: 'Read debugger state, current stop and breakpoint/cleanup status.' },
12
- set_debugger_breakpoint: { fields: { ...session, className: text.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/), line: integer }, command: 'set', description: 'Set an exact binary-class and executable-source-line breakpoint; unloaded classes remain pending. No silent line relocation.' },
13
+ set_debugger_breakpoint: { fields: { ...session, className: text.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/).optional(), sourceFile: text.regex(/^\/[^\0\r\n]+$/).optional(), line: integer }, command: 'set', description: 'Set an exact source breakpoint: Android className or iOS absolute sourceFile (exactly one), plus line; unloaded classes/images remain pending. No silent line relocation.' },
13
14
  remove_debugger_breakpoint: { fields: { ...session, breakpointId: text }, command: 'remove', description: 'Remove an owned breakpoint; repeated removal succeeds.' },
14
15
  wait_for_debugger_event: { fields: { ...session, afterSequence: z.number().int().safe().min(0).optional() }, command: 'events', description: 'Wait for bounded debugger events after a sequence cursor. Empty timeout does not affect execution; overflow is explicit.' },
15
16
  get_debugger_stack: { fields: { ...stop, threadId: text, offset: z.number().int().min(0).max(999).optional(), limit: z.number().int().min(1).max(100).optional() }, command: 'stack', description: 'Read bounded stack frames for a stopped thread; IDs expire on resume.' },
16
- get_debugger_locals: { fields: { ...stop, frameId: text }, command: 'locals', description: 'Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters. No evaluation or field traversal.' },
17
+ get_debugger_locals: { fields: { ...stop, frameId: text }, command: 'locals', description: 'Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters; iOS aggregates return type summaries. No evaluation or field traversal.' },
17
18
  step_debugger: { fields: { ...stop, threadId: text, kind: z.enum(['into', 'over', 'out']) }, command: 'step', description: 'Acknowledge one-shot line stepping and resume. Wait for a subsequent stop event separately.' },
18
19
  resume_debugger: { fields: stop, command: 'resume', description: 'Resume owned debugger suspension and immediately expire stopped references.' },
19
- detach_debugger: { fields: session, command: 'detach', description: 'Dispose the owned debugger connection, release owned suspension and remove its exact forward. Idempotent; retries incomplete cleanup.' }
20
+ detach_debugger: { fields: session, command: 'detach', description: 'Dispose the owned debugger connection, release owned suspension and remove its owned transport. Idempotent; retries incomplete cleanup.' }
20
21
  }
21
22
  // Derive JSON schemas and runtime validation from the same small Zod field vocabulary.
22
23
  function jsonField(schema: z.ZodType): Record<string, unknown> {
@@ -31,12 +32,14 @@ function jsonField(schema: z.ZodType): Record<string, unknown> {
31
32
  throw new Error('Unsupported debugger field schema')
32
33
  }
33
34
  export const debuggerContracts = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, {
34
- ...spec, schema: z.object({ ...spec.fields, responseMode: z.enum(['compact', 'debug']).default('debug'), timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict()
35
+ ...spec, schema: z.object({ ...spec.fields, responseMode: z.enum(['compact', 'debug']).default('debug'), timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict().refine(a => name !== 'set_debugger_breakpoint' || (('className' in a && a.className !== undefined) !== ('sourceFile' in a && a.sourceFile !== undefined)), { message: 'Exactly one breakpoint source selector is required' }).refine(a => !('platform' in a) || a.platform !== 'ios' || ('deviceId' in a && typeof a.deviceId === 'string' && iosUdid.test(a.deviceId)), { message: 'iOS requires an explicit simulator UDID' })
35
36
  }]))
36
37
  export const debuggerTools = Object.entries(debuggerContracts).map(([name, spec]) => ({
37
38
  name, description: spec.description,
38
39
  inputSchema: {
39
40
  type: 'object' as const, additionalProperties: false,
41
+ ...(['get_debugger_capabilities', 'attach_debugger'].includes(name) ? { allOf: [{ if: { properties: { platform: { const: 'ios' } } }, then: { properties: { deviceId: { pattern: iosUdid.source } } } }] } : {}),
42
+ ...(name === 'set_debugger_breakpoint' ? { oneOf: [{ required: ['className'], not: { required: ['sourceFile'] } }, { required: ['sourceFile'], not: { required: ['className'] } }] } : {}),
40
43
  required: Object.entries(spec.fields).filter(([, field]) => !field.isOptional()).map(([key]) => key),
41
44
  properties: { responseMode: { type: 'string', enum: ['compact', 'debug'], default: 'debug' }, ...Object.fromEntries(Object.entries(spec.fields).map(([key, field]) => [key, jsonField(field)])), timeoutMs: { type: 'integer', minimum: 100, maximum: 30000, default: name === 'wait_for_debugger_event' ? 1000 : 10000 } }
42
45
  }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod'
2
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js'
3
+ export const diagnosticsSchema = z.object({ sessionId: z.string().uuid(), resultId: z.string().uuid() }).strict()
4
+ export const interactionDiagnosticsTool: Tool = {
5
+ name: 'get_interaction_diagnostics',
6
+ description: 'Read the retained original interaction or journey result without replaying input or observing the device. Pass diagnostics.session_id/result_id as sessionId/resultId. Session-local memory: ten minutes, 100 results, 16 MiB total, 1 MiB per result. Expired, evicted or restarted results are unavailable. May contain private UI text and errors; compact output is not redaction.',
7
+ inputSchema: { type: 'object', additionalProperties: false, required: ['sessionId', 'resultId'], properties: {
8
+ sessionId: { type: 'string', format: 'uuid' }, resultId: { type: 'string', format: 'uuid' }
9
+ } }
10
+ }
@@ -1,17 +1,23 @@
1
1
  import { z } from 'zod'
2
- const target = { platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional() }
3
- export const traceTargetSchema = z.object(target).strict()
4
- export const traceStartSchema = z.object({ ...target, durationMs: z.number().int().min(1000).max(120000).default(30000) }).strict()
2
+ const target = { platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(), appId: z.string().regex(/^[a-zA-Z0-9._-]+$/).optional(), template: z.enum(['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs']).optional() }
3
+ const validTarget = (value: { platform: string; deviceId?: string; appId?: string; template?: string }) =>
4
+ value.platform === 'ios' ? !!value.deviceId : value.appId === undefined && value.template === undefined
5
+ export const traceTargetSchema = z.object(target).strict().refine(validTarget)
6
+ export const traceStartSchema = z.object({ ...target, durationMs: z.number().int().min(1000).max(120000).default(30000) }).strict().refine(validTarget).refine(value => value.platform !== 'ios' || !!value.appId)
5
7
  export const traceControlSchema = z.object({ traceId: z.string().uuid() }).strict()
6
- const properties = { platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 } }
8
+ const properties = { platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 }, appId: { type: 'string', minLength: 1, pattern: '^[a-zA-Z0-9._-]+$', description: 'iOS only: running development app bundle identifier; required for iOS start.' }, template: { type: 'string', enum: ['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs'], description: 'iOS only. Default Time Profiler; initial simulator support is Time Profiler only.' } }
9
+ const platformRules = (start: boolean) => ({ oneOf: [
10
+ { properties: { platform: { const: 'android' } }, not: { anyOf: [{ required: ['appId'] }, { required: ['template'] }] } },
11
+ { properties: { platform: { const: 'ios' } }, required: start ? ['deviceId', 'appId'] : ['deviceId'] }
12
+ ] })
7
13
  export const systemTraceTools = [
8
- { name: 'get_system_trace_capabilities', description: 'Probe Android Perfetto prerequisites without recording. Device-wide system tracing is optional; iOS is unsupported.',
9
- inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties } },
10
- { name: 'start_system_trace', description: 'Start bounded Android Perfetto recording, returning an owned trace_id. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard. Device-wide traces may contain sensitive system annotations; no upload.',
11
- inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties: { ...properties, durationMs: { type: 'integer', minimum: 1000, maximum: 120000, default: 30000 } } } },
14
+ { name: 'get_system_trace_capabilities', description: 'Probe Android Perfetto or iOS Instruments prerequisites without recording. iOS requires explicit booted simulator deviceId; reports template matrix and unknown profiling permissions.',
15
+ inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties, ...platformRules(false) } },
16
+ { name: 'start_system_trace', description: 'Start bounded Android Perfetto or iOS simulator Time Profiler recording, returning an owned trace_id after readiness. iOS requires explicit deviceId and running appId. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard. Traces can contain sensitive system metadata, logs and paths; local user-managed retention, no upload.',
17
+ inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties: { ...properties, durationMs: { type: 'integer', minimum: 1000, maximum: 120000, default: 30000 } }, ...platformRules(true) } },
12
18
  ...['get_system_trace_status', 'stop_system_trace', 'cancel_system_trace'].map(name => ({ name,
13
19
  description: name === 'get_system_trace_status' ? 'Reconcile an owned trace and retrieve its state; finalise duration-completed capture.' :
14
- name === 'stop_system_trace' ? 'Stop owned capture gracefully and retrieve a local Perfetto artifact. Retry after a transfer/cleanup failure.' : 'Stop owned capture and discard partial files. Already completed local artifacts are retained.',
20
+ name === 'stop_system_trace' ? 'Stop owned capture gracefully and retrieve a local Perfetto file or validated Instruments .trace bundle. Retry after a transfer/cleanup failure.' : 'Stop owned capture and discard partial files. Already completed local artifacts are retained.',
15
21
  inputSchema: { type: 'object' as const, additionalProperties: false, required: ['traceId'], properties: { traceId: { type: 'string', format: 'uuid' } } }
16
22
  }))
17
23
  ]
@@ -1,3 +1,4 @@
1
+ import { interactionDiagnosticsTool } from './schemas/interaction-diagnostics.js'
1
2
  import { verificationModeJson, responseModeJson } from '../contracts/execution-controls.js'
2
3
  import type { Tool } from '@modelcontextprotocol/sdk/types.js'
3
4
  import { manageTools } from './definitions/manage.js'
@@ -53,9 +54,10 @@ const baseOrder = [
53
54
  export const toolDefinitions: Tool[] = [
54
55
  ...debuggerTools,
55
56
  ...systemTraceTools,
57
+ interactionDiagnosticsTool,
56
58
  ...['get_app_readiness', 'wait_for_app_ready'].map(name => ({
57
59
  name,
58
- description: `${name === 'get_app_readiness' ? 'Observe readiness with at most two fresh UI-tree samples.' : 'Wait within a single bounded budget for settled UI evidence.'} Reports loading, transitioning, settled or unknown with evidence and limitations. Managed Android debugger pauses are reported from debugger evidence; unresponsive detection remains unsupported. Settled normalized foreground UI is not proof of app identity, responsiveness or backend completion.`,
60
+ description: `${name === 'get_app_readiness' ? 'Observe readiness with at most two fresh UI-tree samples.' : 'Wait within a single bounded budget for settled UI evidence.'} Reports loading, transitioning, settled or unknown with evidence and limitations. Managed Android and supported iOS simulator debugger pauses are reported from debugger evidence; unresponsive detection remains unsupported. Settled normalized foreground UI is not proof of app identity, responsiveness or backend completion.`,
59
61
  inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties: {
60
62
  ...readinessPropertiesJson,
61
63
  timeoutMs: { ...readinessPropertiesJson.timeoutMs, default: name === 'get_app_readiness' ? 15000 : 30000 },
@@ -70,10 +72,10 @@ for (const tool of toolDefinitions) {
70
72
  tool.inputSchema.properties ??= {}
71
73
  if (['wait_for_ui', 'wait_for_ui_change'].includes(tool.name)) {
72
74
  tool.inputSchema.properties.requireReady = { type: 'boolean', default: false }
73
- tool.description += '\nSet requireReady=true to require both the existing predicate and settled UI readiness, using the same fresh samples and one timeout budget. The readiness summary explains uncertainty. Default predicate semantics are unchanged.'
75
+ tool.description += '\nDevice-readiness blockers prevent readiness success; unknown/unsupported device signals remain explicit.\nSet requireReady=true to require both the existing predicate and settled UI readiness, using the same fresh samples and one timeout budget. The readiness summary explains uncertainty. Default predicate semantics are unchanged.'
74
76
  }
75
77
  if (tool.name === 'get_ui_tree') tool.description += '\nSuccessful observations include snapshot_session_id and snapshot_revision for same-device conditional input. Compact errors preserve the error and cannot authorize an expectation.'
76
- if (tool.name === 'run_journey') tool.description += '\nTap steps may include expectedState from a successful same-device get_ui_tree. A failed condition blocks that input, retains fresh condition evidence even in compact mode, and stops subsequent steps. Conditions are not journey defaults or supported on other step types.'
78
+ if (tool.name === 'run_journey') tool.description += '\nTap steps may include expectedState from a successful same-device get_ui_tree. A failed condition blocks that input, retains fresh condition evidence through a diagnostics reference in compact mode, and stops subsequent steps. Conditions are not journey defaults or supported on other step types.'
77
79
  if (conditionalTools.has(tool.name)) {
78
80
  tool.inputSchema.properties.expectedState = expectedStateJson
79
81
  tool.inputSchema.properties.platform ??= { type: 'string', enum: ['android'] }
@@ -84,11 +86,12 @@ for (const tool of toolDefinitions) {
84
86
  deviceId: { type: 'string', minLength: 1, pattern: '\\S', not: { enum: ['booted', 'default'] } }
85
87
  } }
86
88
  }]
87
- tool.description += '\n\nConditional input: optionally supply expectedState { snapshotSessionId, snapshotRevision, timeoutMs? } from a successful get_ui_tree on the same explicit platform/deviceId. A fresh normalized UI-tree check runs immediately before input, even with verification disabled. PRECONDITION_FAILED or PRECONDITION_UNAVAILABLE means no input was dispatched; inspect condition evidence and re-observe before deciding what to do next. Compact failures include the fresh UI tree. This is not atomic with OS input delivery.'
89
+ tool.description += '\n\nConditional input: optionally supply expectedState { snapshotSessionId, snapshotRevision, timeoutMs? } from a successful get_ui_tree on the same explicit platform/deviceId. A fresh normalized UI-tree check runs immediately before input, even with verification disabled. PRECONDITION_FAILED or PRECONDITION_UNAVAILABLE means no input was dispatched; inspect condition evidence and re-observe before deciding what to do next. Compact failures summarize the fresh condition evidence; use get_interaction_diagnostics with the returned diagnostics reference for the retained UI tree. This is not atomic with OS input delivery.'
88
90
  } else {
89
91
  tool.inputSchema.allOf = [...(Array.isArray(tool.inputSchema.allOf) ? tool.inputSchema.allOf : []), { not: { required: ['expectedState'] } }]
90
92
  }
91
93
  if (['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back'].includes(tool.name)) {
94
+ tool.description += '\nCompact results summarize observations, condition trees, state deltas and readiness probe evidence within a byte budget. Use get_interaction_diagnostics with the returned diagnostics session_id/result_id to retrieve the original result without replaying input. Debug output is unchanged.'
92
95
  Object.assign(tool.inputSchema.properties, {
93
96
  verificationMode: { ...verificationModeJson, default: 'full' },
94
97
  responseMode: { ...responseModeJson, default: 'debug' },
@@ -111,6 +114,7 @@ for (const tool of toolDefinitions) {
111
114
  // Correlation is explicit; an omitted reference creates an independent observation.
112
115
  for (const tool of toolDefinitions) {
113
116
  tool.inputSchema.properties ??= {}
117
+ if (['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'adjust_control', 'get_app_readiness', 'wait_for_app_ready'].includes(tool.name)) tool.description += '\nResults include device_readiness with timestamped device blocker evidence. Android reports supported power/keyguard signals; iOS signals may be unknown or explicitly unsupported. Launch/input dispatch success does not establish app visibility or input readiness. No automatic unlock or wake is performed.'
114
118
  if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'read_log_stream'].includes(tool.name)) {
115
119
  Object.assign(tool.inputSchema.properties, { actionRef: {
116
120
  type: 'object' as const, additionalProperties: false, required: ['sessionId', 'actionId'], properties: {
@@ -11,12 +11,13 @@ import {
11
11
  import { wrapResponse } from './server/common.js'
12
12
  import { toolDefinitions } from './server/tool-definitions.js'
13
13
  import { handleToolCall } from './server/handlers/tool-handlers.js'
14
+ import { withBenchmarkRequest, benchmarkStage } from './utils/benchmark-metrics.js'
14
15
 
15
16
  export { wrapResponse, toolDefinitions, handleToolCall }
16
17
 
17
18
  export const serverInfo = {
18
19
  name: 'mobile-debug-mcp',
19
- version: '0.35.0'
20
+ version: '0.37.0'
20
21
  }
21
22
 
22
23
  export function createServer() {
@@ -48,7 +49,9 @@ export function createServer() {
48
49
 
49
50
  server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema>, extra) => {
50
51
  const { name, arguments: args } = request.params
51
- return handleToolCall(name, args as Record<string, unknown>, extra.signal)
52
+ const operation = () => handleToolCall(name, args as Record<string, unknown>, extra.signal)
53
+ return withBenchmarkRequest(name, () => name.startsWith('expect_') || name.startsWith('wait_for_')
54
+ ? benchmarkStage(name.startsWith('expect_') ? 'assertion_ms' : 'wait_ms', operation) : operation())
52
55
  })
53
56
 
54
57
  return server
package/src/server.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { debuggerSessions } from './debugger/sessions.js'
2
+ import { debuggerSessions, iosDebuggerSessions } from './debugger/sessions.js'
3
3
  import { systemTraces } from './observe/system-trace.js'
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
5
  import { createServer } from './server-core.js'
@@ -9,7 +9,7 @@ const server = createServer()
9
9
 
10
10
  if (process.env.MOBILE_DEBUG_MCP_STARTUP_HEALTHCHECK === '1') {
11
11
  getSystemStatus().then((res) => {
12
- console.info('[startup] system status summary:', { adb: res.adbAvailable, ios: res.iosAvailable, devices: res.devices, iosDevices: res.iosDevices })
12
+ console.info('[startup] system status summary:', { adb: 'adbAvailable' in res ? res.adbAvailable : false, ios: 'iosAvailable' in res ? res.iosAvailable : false, devices: 'devices' in res ? res.devices : 0, iosDevices: 'iosDevices' in res ? res.iosDevices : 0 })
13
13
  }).catch((e) => console.warn('[startup] healthcheck failed:', e instanceof Error ? e.message : String(e)))
14
14
  }
15
15
 
@@ -22,8 +22,8 @@ async function shutdown() {
22
22
  const deadline = setTimeout(() => { console.error('System trace shutdown cleanup timed out; owned device captures remain duration-bounded.'); process.exit(1) }, 10000)
23
23
  let exitCode = 0
24
24
  try {
25
- const [remaining, debuggers] = await Promise.all([systemTraces.shutdown(), debuggerSessions.shutdown()])
26
- if (debuggers.length) { exitCode = 1; console.error('Debugger shutdown cleanup incomplete', debuggers.map(s => s.cleanup)) }
25
+ const [remaining, debuggers, iosDebuggers] = await Promise.all([systemTraces.shutdown(), debuggerSessions.shutdown(), iosDebuggerSessions.shutdown()])
26
+ if ((debuggers.length + iosDebuggers.length)) { exitCode = 1; console.error('Debugger shutdown cleanup incomplete', [...debuggers, ...iosDebuggers].map(s => s.cleanup)) }
27
27
  if (remaining.length) {
28
28
  exitCode = 1
29
29
  console.error('System trace shutdown cleanup incomplete:', remaining.map(s => ({ trace_id: s.trace_id, device: s.device, cleanup: s.cleanup })))
@@ -1,3 +1,4 @@
1
+ import { probeInstallation, validAppId, type AppInstallation } from '../utils/app-installation.js'
1
2
  import { execSync } from 'child_process'
2
3
  import { ensureAdbAvailable } from '../utils/android/utils.js'
3
4
 
@@ -10,6 +11,8 @@ export async function checkAndroid() {
10
11
  let logsAvailable = false
11
12
  let envValid = false
12
13
  let appInstalled: boolean | undefined = undefined
14
+ let appInstallations: AppInstallation[] = []
15
+ let targets: { id: string; available: boolean }[] = []
13
16
 
14
17
  try {
15
18
  const adbCheck = ensureAdbAvailable()
@@ -21,6 +24,7 @@ export async function checkAndroid() {
21
24
  try {
22
25
  const out = execSync(`${adbCmd} devices -l`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
23
26
  const state = parseDevices(out, issues)
27
+ targets = out.split('\n').map(line => line.trim().split(/\s+/)).filter(parts => parts[0] && !['List', '*'].includes(parts[0])).map(parts => ({ id: parts[0], available: parts[1] === 'device' }))
24
28
  devices = state.devices; deviceStates = state.deviceStates
25
29
 
26
30
  } catch (e: unknown) { console.debug('[get_system_status] adb devices failed: ' + String(e)); issues.push('Failed to list Android devices') }
@@ -38,12 +42,12 @@ export async function checkAndroid() {
38
42
  if (!envValid) issues.push('ANDROID_SDK_ROOT/ANDROID_HOME missing and adb not found in PATH')
39
43
 
40
44
  const pkg = process.env.MCP_TARGET_PACKAGE || process.env.MCP_TARGET_APP_ID
41
- if (pkg && adbAvailable && devices > 0) {
42
- appInstalled = probeAppInstalled(adbCmd, pkg, issues)
43
- }
45
+ appInstallations = await Promise.all(targets.map(target => probeInstallation(adbCmd, target.id, pkg, 'android-current-user', adbAvailable && target.available)))
46
+ reportInstallations(appInstallations, pkg, issues)
47
+ appInstalled = installationSummary(appInstallations)
44
48
  } catch (e: unknown) { console.debug('[get_system_status] adb availability check failed: ' + String(e)); issues.push('ADB check failed') }
45
49
 
46
- return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, issues }
50
+ return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, appInstallations, issues }
47
51
  }
48
52
 
49
53
  function parseDevices(out: string, issues: string[]) {
@@ -64,11 +68,14 @@ function parseDevices(out: string, issues: string[]) {
64
68
  return { devices, deviceStates }
65
69
  }
66
70
 
67
- function probeAppInstalled(adbCmd: string, pkg: string, issues: string[]) {
68
- try {
69
- const pm = execSync(`${adbCmd} shell pm path ${pkg}`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
70
- const appInstalled = (pm || '').includes('package:')
71
- if (!appInstalled) issues.push(`App ${pkg} not installed on devices`)
72
- return appInstalled
73
- } catch (e: unknown) { console.debug('[get_system_status] pm check failed: ' + String(e)); return false }
71
+
72
+ function installationSummary(checks: AppInstallation[]) {
73
+ return checks.length === 1 && checks[0].status !== 'unknown' ? checks[0].status === 'installed' : undefined
74
+ }
75
+ function reportInstallations(checks: AppInstallation[], pkg: string | undefined, issues: string[]) {
76
+ if (pkg !== undefined && !validAppId(pkg)) issues.push('Invalid target app identifier')
77
+ for (const check of checks) {
78
+ if (check.status === 'absent') issues.push(`App ${check.appId} not installed on device ${check.deviceId}`)
79
+ if (check.status === 'unknown' && check.reason !== 'APP_ID_NOT_PROVIDED') issues.push(`Installation check unavailable on device ${check.deviceId}: ${check.reason}`)
80
+ }
74
81
  }
@@ -47,7 +47,8 @@ export async function getSystemStatus() {
47
47
  logsAvailable: androidRaw.logsAvailable,
48
48
  envValid: androidRaw.envValid,
49
49
  issues,
50
- appInstalled: androidRaw.appInstalled,
50
+ ...(androidRaw.appInstalled === undefined ? {} : { appInstalled: androidRaw.appInstalled }),
51
+ appInstallations: androidRaw.appInstallations,
51
52
  iosAvailable: iosRaw.iosAvailable,
52
53
  iosDevices: iosRaw.iosDevices,
53
54
  gradleJavaHome: gradle.gradleJavaHome,
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@ export interface DeviceInfo {
7
7
  model: string;
8
8
  simulator: boolean;
9
9
  appInstalled?: boolean;
10
+ appInstallation?: import('./utils/app-installation.js').AppInstallation;
10
11
  booted?: boolean;
11
12
  }
12
13
 
@@ -355,6 +356,7 @@ export interface ActionConditionResult {
355
356
  }
356
357
 
357
358
  export interface ActionExecutionResult {
359
+ device_readiness?: import('./contracts/device-readiness.js').DeviceReadiness;
358
360
  action_id: string;
359
361
  timestamp: string;
360
362
  action_type: string;