mobile-debug-mcp 0.31.0 → 0.32.1

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 (109) hide show
  1. package/README.md +1 -0
  2. package/dist/interact/android.js +13 -8
  3. package/dist/interact/index.js +33 -22
  4. package/dist/interact/ios.js +5 -2
  5. package/dist/manage/android.js +20 -10
  6. package/dist/manage/ios.js +19 -10
  7. package/dist/observe/android-tree-bridge.js +192 -0
  8. package/dist/observe/android.js +53 -14
  9. package/dist/observe/index.js +22 -5
  10. package/dist/observe/ios.js +50 -29
  11. package/dist/observe/performance-metrics.js +124 -0
  12. package/dist/observe/snapshot-metadata.js +94 -15
  13. package/dist/server/common.js +14 -7
  14. package/dist/server/journey-schema.js +60 -0
  15. package/dist/server/tool-definitions.js +59 -6
  16. package/dist/server/tool-handlers.js +381 -56
  17. package/dist/server-core.js +1 -1
  18. package/dist/utils/exec.js +18 -5
  19. package/dist/utils/ios/utils.js +5 -2
  20. package/dist/utils/operation-budget.js +25 -0
  21. package/dist/utils/resolve-device.js +12 -1
  22. package/dist/utils/timing.js +19 -0
  23. package/dist/utils/ui/index.js +2 -0
  24. package/docs/CHANGELOG.md +6 -0
  25. package/docs/ROADMAP.md +9 -0
  26. package/docs/benchmarks/interaction-latency/README.md +26 -0
  27. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  28. package/docs/benchmarks/interaction-latency/final.md +17 -0
  29. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  30. package/docs/benchmarks/ui-tree-performance/README.md +51 -0
  31. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  32. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
  33. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  34. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  35. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  36. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  37. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  38. package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
  39. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  40. package/docs/tools/interact.md +30 -0
  41. package/docs/tools/observe.md +1 -0
  42. package/package.json +1 -1
  43. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  44. package/src/interact/android.ts +13 -8
  45. package/src/interact/index.ts +32 -23
  46. package/src/interact/ios.ts +5 -2
  47. package/src/manage/android.ts +19 -10
  48. package/src/manage/ios.ts +18 -10
  49. package/src/observe/android-tree-bridge.ts +170 -0
  50. package/src/observe/android.ts +48 -17
  51. package/src/observe/index.ts +21 -5
  52. package/src/observe/ios.ts +49 -36
  53. package/src/observe/performance-metrics.ts +125 -0
  54. package/src/observe/snapshot-metadata.ts +94 -15
  55. package/src/server/common.ts +13 -6
  56. package/src/server/journey-schema.ts +63 -0
  57. package/src/server/tool-definitions.ts +60 -7
  58. package/src/server/tool-handlers.ts +349 -54
  59. package/src/server-core.ts +1 -1
  60. package/src/types.ts +31 -2
  61. package/src/utils/exec.ts +13 -5
  62. package/src/utils/ios/utils.ts +5 -2
  63. package/src/utils/operation-budget.ts +22 -0
  64. package/src/utils/resolve-device.ts +13 -1
  65. package/src/utils/timing.ts +15 -0
  66. package/src/utils/ui/index.ts +2 -0
  67. package/test/device/automated/latency/benchmark.ts +136 -0
  68. package/test/device/automated/latency/report.ts +6 -0
  69. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  70. package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
  71. package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
  72. package/test/fixtures/latency-journey-app/README.md +28 -0
  73. package/test/fixtures/latency-journey-app/build.ts +26 -0
  74. package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
  75. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  76. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  77. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
  78. package/test/unit/manage/restart_budget.test.ts +37 -0
  79. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  80. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
  81. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  82. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  83. package/test/unit/observe/performance_metrics.test.ts +40 -0
  84. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  85. package/test/unit/observe/state_delta.test.ts +15 -0
  86. package/test/unit/server/benchmark_report.test.ts +4 -0
  87. package/test/unit/server/contract.test.ts +1 -1
  88. package/test/unit/server/interaction_modes.test.ts +60 -0
  89. package/test/unit/server/operation_budget.test.ts +45 -0
  90. package/test/unit/server/response_shapes.test.ts +3 -3
  91. package/test/unit/server/run_journey.test.ts +120 -0
  92. package/test/unit/server/tap_selector.test.ts +68 -0
  93. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  94. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  95. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  96. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  97. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  98. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  99. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  100. /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
  101. /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
  102. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  103. /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
  104. /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
  105. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  106. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  107. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  108. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  109. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
@@ -1,3 +1,8 @@
1
+ import { measure, withTiming } from '../utils/timing.js'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { runWithBudget } from '../utils/operation-budget.js'
4
+ import { journeySchema, tapElementControlsSchema } from './journey-schema.js'
5
+ import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
1
6
  import type {
2
7
  InstallAppResponse,
3
8
  ResetAppDataResponse,
@@ -9,6 +14,7 @@ import { ToolsObserve } from '../observe/index.js'
9
14
  import { classifyActionOutcome } from '../interact/classify.js'
10
15
  import { ToolsNetwork } from '../network/index.js'
11
16
  import { getSystemStatus } from '../system/index.js'
17
+ import { recordObservationConsumer, recordObservationEvent, withObservationContext } from '../observe/performance-metrics.js'
12
18
  import {
13
19
  buildActionExecutionResult,
14
20
  captureActionFingerprint,
@@ -24,6 +30,7 @@ import {
24
30
  requireObjectArg,
25
31
  requireStringArg,
26
32
  ToolCallArgs,
33
+ ToolCallResult,
27
34
  ToolHandler,
28
35
  wrapResponse,
29
36
  wrapToolError
@@ -36,16 +43,122 @@ type WaitForUiMatchArg = { index?: number }
36
43
  type WaitForUiRetryArg = { max_attempts?: number, backoff_ms?: number }
37
44
  type ScrollSelectorArg = { text?: string, resourceId?: string, contentDesc?: string, className?: string }
38
45
  type ClassifyNetworkRequestArg = { endpoint: string, status: 'success' | 'failure' | 'retryable' }
46
+ type VerificationMode = 'none' | 'light' | 'full'
47
+ type ResponseMode = 'compact' | 'debug'
39
48
 
40
- async function handleStartApp(args: ToolCallArgs) {
49
+ function interactionControls(args: ToolCallArgs) {
50
+ const verificationMode = (getStringArg(args, 'verificationMode') as VerificationMode | undefined) ?? 'full'
51
+ const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
52
+ if (!['none', 'light', 'full'].includes(verificationMode) || !['compact', 'debug'].includes(responseMode)) throw new Error('INVALID_ARGUMENT')
53
+ for (const key of ['verificationMode', 'responseMode']) if (args[key] !== undefined && typeof args[key] !== 'string') throw new Error('INVALID_ARGUMENT')
54
+ for (const key of ['actionTimeoutMs', 'verificationTimeoutMs']) if (args[key] !== undefined && (typeof args[key] !== 'number' || !Number.isInteger(args[key]))) throw new Error('INVALID_ARGUMENT')
55
+ const actionTimeoutMs = getNumberArg(args, 'actionTimeoutMs')
56
+ const verificationTimeoutMs = getNumberArg(args, 'verificationTimeoutMs')
57
+ return { verificationMode, responseMode,
58
+ actionTimeoutMs: actionTimeoutMs === undefined ? undefined : Math.max(100, Math.min(30000, actionTimeoutMs)),
59
+ verificationTimeoutMs: verificationTimeoutMs === undefined ? 3000 : Math.max(100, Math.min(60000, verificationTimeoutMs)) }
60
+
61
+ }
62
+
63
+ function compactAction(result: any, verificationMode: VerificationMode, started = true, stateDelta?: unknown, totalMs = 0) {
64
+ return {
65
+ action_id: result.action_id,
66
+ action_type: result.action_type,
67
+ success: !!result.success,
68
+ lifecycle_state: result.success ? (verificationMode === 'full' ? result.lifecycle_state : 'pending_verification') : 'failed',
69
+ ...(result.failure_code ? { failure_code: result.failure_code } : {}),
70
+ ...(typeof result.retryable === 'boolean' ? { retryable: result.retryable } : {}),
71
+ delivery_status: result.success ? 'delivered' : (started ? 'unknown' : 'not_delivered'),
72
+ dispatch_started: started,
73
+ verification: { mode: verificationMode, status: result.success ? (verificationMode === 'none' ? 'not_requested' : verificationMode === 'light' ? 'observed' : (result.lifecycle_state === 'verified' ? 'verified' : 'observed')) : 'failed' },
74
+ ...(stateDelta ? { state_delta: stateDelta } : {}),
75
+ timing: { total_ms: totalMs }
76
+ }
77
+ }
78
+
79
+ function actionHandler(type: string, execute: ToolHandler): ToolHandler {
80
+ return async (args) => {
81
+ const timing: Record<string, number> = {}
82
+ return withTiming(timing, async () => {
83
+ const started = performance.now()
84
+ const controls = interactionControls(args)
85
+ const budgetMs = controls.actionTimeoutMs ?? ({ start_app: 10000, restart_app: 15000, scroll_to_element: 10000 }[type] ?? 5000)
86
+ let dispatchStarted = false
87
+ let result: any
88
+ const dispatchWithBudget = (timeoutMs: number) => runWithBudget(timeoutMs, async (budget) => {
89
+ try { return payloadOf(await execute(args)) }
90
+ finally { dispatchStarted = budget.dispatchStarted }
91
+ })
92
+ try {
93
+ // Omitted controls retain the existing adapter budgets.
94
+ result = args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined
95
+ ? await dispatchWithBudget(Infinity) : await dispatchWithBudget(budgetMs)
96
+ if (result.success) dispatchStarted = true
97
+ } catch (error) {
98
+ if (controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return wrapToolError(type, error)
99
+ result = { success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
100
+ }
101
+ if (result.failure_code === 'TIMEOUT' || /ACTION_TIMEOUT|timeout/i.test(result.error ?? result.reason ?? result.details?.error ?? '')) result.failure_code = 'ACTION_TIMEOUT'
102
+ if (result.failure_code === 'ACTION_TIMEOUT') result.retryable = !dispatchStarted
103
+ if (!result.success && typeof result.retryable !== 'boolean') result.retryable = !dispatchStarted
104
+ result.action_id ??= randomUUID()
105
+ result.action_type ??= type
106
+ recordObservationEvent({
107
+ step_id: args._stepId as string | undefined,
108
+ action_type: type,
109
+ dispatch_ms: timing.dispatch_ms,
110
+ verification_mode: controls.verificationMode,
111
+ dispatch_started: dispatchStarted
112
+ })
113
+ let delta: unknown
114
+ let observationAvailable = false
115
+ if (result.success && controls.verificationMode === 'light') {
116
+ const verificationStarted = performance.now()
117
+ const tree = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'verification' }, () =>
118
+ measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () =>
119
+ ToolsObserve.getUITreeHandler({ platform: (args.platform as PlatformArg | undefined) ?? result.device?.platform, deviceId: (args.deviceId as string | undefined) ?? result.device?.id })))).catch(() => null) as any
120
+ observationAvailable = !!tree && !tree.error
121
+ recordObservationEvent({
122
+ verification_budget_ms: controls.verificationTimeoutMs,
123
+ verification_elapsed_ms: performance.now() - verificationStarted,
124
+ verification_outcome: observationAvailable ? 'observed' : 'unavailable',
125
+ verification_budget_expired: !observationAvailable && performance.now() - verificationStarted >= controls.verificationTimeoutMs
126
+ })
127
+ if (observationAvailable && tree.device) delta = ToolsObserve.getLatestStateDelta(tree.device.platform, tree.device.id) ?? undefined
128
+ }
129
+ const compact = compactAction(result, controls.verificationMode, dispatchStarted, delta, performance.now() - started)
130
+ if (result.success && controls.verificationMode === 'light' && !observationAvailable) compact.verification.status = 'unavailable'
131
+ const evidence = {
132
+ ...compact,
133
+ ...(controls.verificationMode === 'light' ? { state_delta_available: !!delta } : {}),
134
+ ...(result.success && controls.verificationMode === 'light' && !observationAvailable ? { verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true } } : {})
135
+ }
136
+ return wrapResponse(controls.responseMode === 'compact' ? evidence : {
137
+ ...result, ...evidence, timing: { ...timing, total_ms: performance.now() - started },
138
+ effective_controls: { ...controls, actionTimeoutMs: args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined ? null : budgetMs, timeout_bound: !(args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined) }
139
+ }, controls.responseMode === 'debug')
140
+ })
141
+ }
142
+ }
143
+ const handleStartApp = actionHandler('start_app', executeStartApp)
144
+ const handleRestartApp = actionHandler('restart_app', executeRestartApp)
145
+ const handleTap = actionHandler('tap', executeTap)
146
+ const handleTapElement = actionHandler('tap_element', executeTapElement)
147
+ const handleSwipe = actionHandler('swipe', executeSwipe)
148
+ const handleScrollToElement = actionHandler('scroll_to_element', executeScrollToElement)
149
+ const handleTypeText = actionHandler('type_text', executeTypeText)
150
+ const handlePressBack = actionHandler('press_back', executePressBack)
151
+
152
+ async function executeStartApp(args: ToolCallArgs) {
41
153
  const platform = requireStringArg(args, 'platform') as PlatformArg
42
154
  const appId = requireStringArg(args, 'appId')
43
155
  const deviceId = getStringArg(args, 'deviceId')
44
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
156
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
157
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
45
158
  ToolsNetwork.notifyActionStart()
46
- const res = await (platform === 'android' ? new AndroidManage().startApp(appId, deviceId) : new iOSManage().startApp(appId, deviceId))
47
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
48
- return wrapResponse(buildActionExecutionResult({
159
+ const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().startApp(appId, deviceId, actionTimeoutMs ?? (verificationMode === 'full' && getStringArg(args, 'responseMode') === undefined && args._journey !== true ? undefined : 10000)) : new iOSManage().startApp(appId, deviceId, verificationMode === 'full')))
160
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
161
+ const result = buildActionExecutionResult({
49
162
  actionType: 'start_app',
50
163
  sourceModule: 'server',
51
164
  device: res.device,
@@ -59,9 +172,11 @@ async function handleStartApp(args: ToolCallArgs) {
59
172
  ...(typeof res.output === 'string' ? { output: res.output } : {}),
60
173
  ...(res.device ? { device_id: res.device.id } : {}),
61
174
  ...(typeof res.error === 'string' ? { error: res.error } : {}),
175
+ ...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
62
176
  ...(res.observedApp ? { observed_app: res.observedApp } : {})
63
177
  }
64
- }))
178
+ })
179
+ return wrapResponse(result)
65
180
  }
66
181
 
67
182
  async function handleTerminateApp(args: ToolCallArgs) {
@@ -73,15 +188,17 @@ async function handleTerminateApp(args: ToolCallArgs) {
73
188
  return wrapResponse(response)
74
189
  }
75
190
 
76
- async function handleRestartApp(args: ToolCallArgs) {
191
+ async function executeRestartApp(args: ToolCallArgs) {
77
192
  const platform = requireStringArg(args, 'platform') as PlatformArg
78
193
  const appId = requireStringArg(args, 'appId')
79
194
  const deviceId = getStringArg(args, 'deviceId')
80
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
195
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
196
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
81
197
  ToolsNetwork.notifyActionStart()
82
- const res = await (platform === 'android' ? new AndroidManage().restartApp(appId, deviceId) : new iOSManage().restartApp(appId, deviceId))
83
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
84
- return wrapResponse(buildActionExecutionResult({
198
+ const bounded = args._journey === true || verificationMode !== 'full' || args.responseMode !== undefined || actionTimeoutMs !== undefined
199
+ const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().restartApp(appId, deviceId, bounded, actionTimeoutMs ?? 15000) : new iOSManage().restartApp(appId, deviceId, verificationMode === 'full', bounded, actionTimeoutMs ?? 15000)))
200
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
201
+ const result = buildActionExecutionResult({
85
202
  actionType: 'restart_app',
86
203
  sourceModule: 'server',
87
204
  device: res.device,
@@ -96,9 +213,11 @@ async function handleRestartApp(args: ToolCallArgs) {
96
213
  ...(typeof res.terminatedBeforeRestart === 'boolean' ? { terminated_before_restart: res.terminatedBeforeRestart } : {}),
97
214
  ...(typeof res.terminateError === 'string' ? { terminate_error: res.terminateError } : {}),
98
215
  ...(typeof res.error === 'string' ? { error: res.error } : {}),
216
+ ...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
99
217
  ...(res.observedApp ? { observed_app: res.observedApp } : {})
100
218
  }
101
- }))
219
+ })
220
+ return wrapResponse(result)
102
221
  }
103
222
 
104
223
  async function handleResetAppData(args: ToolCallArgs) {
@@ -185,10 +304,12 @@ async function handleGetSystemStatus() {
185
304
  async function handleCaptureScreenshot(args: ToolCallArgs) {
186
305
  const platform = requireStringArg(args, 'platform') as PlatformArg
187
306
  const deviceId = getStringArg(args, 'deviceId')
307
+ const started = performance.now()
188
308
  const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
309
+ const screenshotMs = performance.now() - started
189
310
  const mime = (res as any).screenshot_mime || 'image/png'
190
311
  const content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }> = [
191
- { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime } }, null, 2) },
312
+ { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime }, timing: { total_ms: screenshotMs, screenshot_ms: screenshotMs } }, null, 2) },
192
313
  { type: 'image', data: (res as any).screenshot, mimeType: mime }
193
314
  ]
194
315
  if ((res as any).screenshot_fallback) {
@@ -206,14 +327,19 @@ async function handleCaptureDebugSnapshot(args: ToolCallArgs) {
206
327
  const appId = getStringArg(args, 'appId')
207
328
  const deviceId = getStringArg(args, 'deviceId')
208
329
  const sessionId = getStringArg(args, 'sessionId')
209
- const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, logLines, platform, appId, deviceId, sessionId })
330
+ const includeScreenshot = getBooleanArg(args, 'includeScreenshot')
331
+ const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, includeScreenshot, logLines, platform, appId, deviceId, sessionId })
210
332
  return wrapResponse(res)
211
333
  }
212
334
 
213
335
  async function handleGetUITree(args: ToolCallArgs) {
214
336
  const platform = requireStringArg(args, 'platform') as PlatformArg
215
337
  const deviceId = getStringArg(args, 'deviceId')
216
- const res = await ToolsObserve.getUITreeHandler({ platform, deviceId })
338
+ const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
339
+ const sinceSnapshotRevision = getNumberArg(args, 'sinceSnapshotRevision')
340
+ const metricsStepId = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1' ? getStringArg(args, '_metricsStepId') : undefined
341
+ const res = await withObservationContext({ stepId: metricsStepId, purpose: metricsStepId?.includes('post-response') ? 'assertion' : 'debug' }, () =>
342
+ ToolsObserve.getUITreeHandler({ platform, deviceId, responseMode, sinceSnapshotRevision }))
217
343
  return wrapResponse(res)
218
344
  }
219
345
 
@@ -333,20 +459,23 @@ async function handleFindElement(args: ToolCallArgs) {
333
459
  const timeoutMs = getNumberArg(args, 'timeoutMs') ?? 3000
334
460
  const platform = getStringArg(args, 'platform') as PlatformArg | undefined
335
461
  const deviceId = getStringArg(args, 'deviceId')
336
- const res = await ToolsInteract.findElementHandler({ query, exact, timeoutMs, platform, deviceId })
462
+ const targetStarted = performance.now()
463
+ const res = await withObservationContext({ purpose: 'target' }, () => ToolsInteract.findElementHandler({ query, exact, timeoutMs, platform, deviceId }))
464
+ recordObservationEvent({ target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: res.found ? 'found' : 'not_found', operation: 'find_element' })
337
465
  return wrapResponse(res)
338
466
  }
339
467
 
340
- async function handleTap(args: ToolCallArgs) {
468
+ async function executeTap(args: ToolCallArgs) {
341
469
  const platform = getStringArg(args, 'platform') as PlatformArg | undefined
342
470
  const x = requireNumberArg(args, 'x')
343
471
  const y = requireNumberArg(args, 'y')
344
472
  const deviceId = getStringArg(args, 'deviceId')
345
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
473
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
474
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
346
475
  ToolsNetwork.notifyActionStart()
347
- const res = await ToolsInteract.tapHandler({ platform, x, y, deviceId })
348
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
349
- return wrapResponse(buildActionExecutionResult({
476
+ const res = await measure('dispatch_ms', () => ToolsInteract.tapHandler({ platform, x, y, deviceId, timeoutMs: actionTimeoutMs }))
477
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
478
+ const result = buildActionExecutionResult({
350
479
  actionType: 'tap',
351
480
  sourceModule: 'server',
352
481
  selector: { x, y },
@@ -354,17 +483,158 @@ async function handleTap(args: ToolCallArgs) {
354
483
  uiFingerprintBefore,
355
484
  uiFingerprintAfter,
356
485
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
357
- }))
486
+ })
487
+ return wrapResponse(result)
358
488
  }
359
489
 
360
- async function handleTapElement(args: ToolCallArgs) {
361
- const elementId = requireStringArg(args, 'elementId')
490
+ async function executeTapElement(args: ToolCallArgs) {
491
+ const controls = tapElementControlsSchema.safeParse({ elementId: args.elementId, selector: args.selector, waitFor: args.waitFor })
492
+ if (!controls.success) throw new Error(`INVALID_ARGUMENT: ${controls.error.message}`)
493
+ const elementId = controls.data.elementId
494
+ const selector = controls.data.selector as ExpectElementSelectorArg | undefined
495
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
496
+ let resolvedId = elementId
497
+ let freshTree: any
498
+ if (selector) {
499
+ const waitFor = getObjectArg<WaitForUiMatchArg>(args, 'waitFor') as any
500
+ const targetStarted = performance.now()
501
+ const resolution = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'target' }, () =>
502
+ measure('target_resolution_ms', () => runWithBudget(waitFor?.timeoutMs ?? 1500, () => ToolsInteract.waitForUIHandler({ selector, condition: waitFor?.condition ?? 'clickable', timeout_ms: waitFor?.timeoutMs ?? 1500, poll_interval_ms: waitFor?.pollIntervalMs ?? 100, match: waitFor?.match, singleObservation: true, singleAttempt: !waitFor, rejectAmbiguous: !waitFor, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })))) as any
503
+ recordObservationEvent({ step_id: args._stepId as string | undefined, purpose: 'target', target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: resolution.status })
504
+ if (resolution.status !== 'success' || !resolution.element?.elementId) return wrapResponse({ success: false, action_type: 'tap_element', failure_code: resolution.error?.code ?? 'ELEMENT_NOT_FOUND', retryable: true })
505
+ resolvedId = resolution.element.elementId
506
+ freshTree = resolution._tree
507
+ }
362
508
  ToolsNetwork.notifyActionStart()
363
- const res = await ToolsInteract.tapElementHandler({ elementId })
509
+ if (freshTree) recordObservationConsumer(freshTree, 'target', args._stepId as string | undefined)
510
+ const res = await measure('dispatch_ms', () => ToolsInteract.tapElementHandler({ elementId: resolvedId!, freshTree, verificationMode, timeoutMs: actionTimeoutMs, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })) as any
364
511
  return wrapResponse(res)
365
512
  }
366
513
 
367
- async function handleSwipe(args: ToolCallArgs) {
514
+ function payloadOf(response: ToolCallResult) {
515
+ const text = response.content.find((item: any) => item.type === 'text')?.text
516
+ return text ? JSON.parse(text) : null
517
+ }
518
+
519
+ async function expectElementAbsent({ selector, timeoutMs, pollIntervalMs, platform, deviceId }: { selector: ExpectElementSelectorArg, timeoutMs: number, pollIntervalMs: number, platform: PlatformArg, deviceId?: string }) {
520
+ const deadline = performance.now() + timeoutMs
521
+ let consecutive = 0
522
+ let observed: unknown
523
+ while (performance.now() < deadline) {
524
+ const result = await ToolsInteract.waitForUIHandler({ selector, condition: 'not_exists', timeout_ms: 0, singleObservation: true, singleAttempt: true, platform, deviceId }) as any
525
+ observed = result.observed
526
+ consecutive = result.status === 'success' ? consecutive + 1 : 0
527
+ if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed }
528
+ const delay = Math.max(100, pollIntervalMs)
529
+ if (performance.now() + delay >= deadline) break
530
+ await new Promise(resolve => setTimeout(resolve, delay))
531
+ }
532
+ return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed }
533
+ }
534
+
535
+ async function expectFingerprint({ fingerprint, timeoutMs, platform, deviceId }: { fingerprint: string, timeoutMs: number, platform: PlatformArg, deviceId?: string }) {
536
+ const deadline = performance.now() + timeoutMs
537
+ let consecutive = 0
538
+ let observed: string | null = null
539
+ while (performance.now() < deadline) {
540
+ const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
541
+ observed = result?.fingerprint ?? null
542
+ consecutive = observed === fingerprint ? consecutive + 1 : 0
543
+ if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed_fingerprint: observed }
544
+ if (performance.now() + 100 >= deadline) break
545
+ await new Promise(resolve => setTimeout(resolve, 100))
546
+ }
547
+ return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed_fingerprint: observed }
548
+ }
549
+
550
+ async function handleRunJourney(args: ToolCallArgs) {
551
+ const parsed = journeySchema.safeParse(args)
552
+ if (!parsed.success) throw new Error(`INVALID_ARGUMENT: ${parsed.error.message}`)
553
+ const { platform, responseMode, defaults, captureOnFailure } = parsed.data
554
+ const steps = parsed.data.steps as Array<Record<string, any>>
555
+ const device = await resolveTargetDevice({ platform, deviceId: parsed.data.deviceId })
556
+ const deviceId = device.id
557
+ return withResolvedDevice(device, async () => {
558
+ const started = performance.now()
559
+ const results: any[] = []
560
+ let stoppedAt: string | null = null
561
+ let pendingLightStep: number | null = null
562
+ for (let index = 0; index < steps.length; index++) {
563
+ const step = steps[index]
564
+ const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full'
565
+ const next = steps[index + 1]
566
+ const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
567
+ next.assertion?.kind === 'element_visible'
568
+ const stepStart = performance.now()
569
+ let result: any
570
+ if (stoppedAt) {
571
+ results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } })
572
+ continue
573
+ }
574
+ try {
575
+ if (step.type === 'tap') {
576
+ result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
577
+ } else if (step.type === 'wait') {
578
+ result = await withObservationContext({ stepId: step.id, purpose: 'target' }, () => ToolsInteract.waitForUIHandler({ selector: step.selector as ExpectElementSelectorArg, condition: (step.condition as any) ?? 'exists', timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId }))
579
+ result = { success: result.status === 'success', ...result }
580
+ } else if (step.type === 'assert') {
581
+ result = await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
582
+ const assertion = step.assertion as any
583
+ if (assertion?.kind === 'element_visible') result = await ToolsInteract.expectElementVisibleHandler({ selector: assertion.selector, timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
584
+ else if (assertion?.kind === 'element_absent') result = await expectElementAbsent({ selector: assertion.selector, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, pollIntervalMs: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
585
+ else if (assertion?.kind === 'screen_fingerprint') result = await expectFingerprint({ fingerprint: assertion.fingerprint, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, platform, deviceId })
586
+ else if (assertion?.kind === 'state_equals') result = await ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, exact_budget: true })
587
+ else result = { success: false, failure_code: 'INVALID_ASSERTION' }
588
+ return result
589
+ }))
590
+ } else if (step.type === 'start_app') {
591
+ result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
592
+ } else {
593
+ result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
594
+ }
595
+ } catch (error) {
596
+ result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
597
+ }
598
+ if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
599
+ const success = !!result?.success
600
+ if (lightCoveredByAssertion) {
601
+ result.verification = { mode: 'light', status: success ? 'unavailable' : 'failed' }
602
+ if (success) {
603
+ result.verification_diagnostic = { code: 'VERIFICATION_UNAVAILABLE', retryable: true }
604
+ pendingLightStep = results.length
605
+ }
606
+ }
607
+ if (step.type === 'assert' && pendingLightStep !== null) {
608
+ const pending = results[pendingLightStep].result
609
+ if (typeof result?.observed?.matched_count === 'number') {
610
+ pending.verification.status = 'observed'
611
+ delete pending.verification_diagnostic
612
+ const delta = ToolsObserve.getLatestStateDelta(platform, deviceId)
613
+ if (delta) { pending.state_delta = delta; pending.state_delta_available = true }
614
+ }
615
+ pendingLightStep = null
616
+ }
617
+ if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type)) result = {
618
+ success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
619
+ ...(result.error?.code ? { failure_code: result.error.code } : {}),
620
+ ...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
621
+ ...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
622
+ ...(result.actual_state ? { observed: result.actual_state } : {})
623
+ }
624
+ results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - stepStart } })
625
+ if (!success) stoppedAt = step.id as string
626
+ }
627
+ const failureSnapshot = stoppedAt && captureOnFailure && responseMode === 'debug'
628
+ ? await ToolsObserve.captureDebugSnapshotHandler({ platform, deviceId, includeScreenshot: false }).catch(() => null) : undefined
629
+ const componentTiming: Record<string, number> = {}
630
+ for (const step of results) for (const [key, value] of Object.entries(step.result?.timing ?? {})) {
631
+ if (key !== 'total_ms' && typeof value === 'number') componentTiming[key] = (componentTiming[key] ?? 0) + value
632
+ }
633
+ return wrapResponse({ success: stoppedAt === null, stopped_at_step_id: stoppedAt, steps: results, ...(failureSnapshot ? { failure_snapshot: failureSnapshot } : {}), timing: responseMode === 'compact' ? { total_ms: performance.now() - started } : { ...componentTiming, total_ms: performance.now() - started } }, responseMode === 'debug')
634
+ })
635
+ }
636
+
637
+ async function executeSwipe(args: ToolCallArgs) {
368
638
  const platform = (getStringArg(args, 'platform') as PlatformArg | undefined) ?? 'android'
369
639
  const x1 = requireNumberArg(args, 'x1')
370
640
  const y1 = requireNumberArg(args, 'y1')
@@ -372,11 +642,12 @@ async function handleSwipe(args: ToolCallArgs) {
372
642
  const y2 = requireNumberArg(args, 'y2')
373
643
  const duration = requireNumberArg(args, 'duration')
374
644
  const deviceId = getStringArg(args, 'deviceId')
375
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
645
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
646
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
376
647
  ToolsNetwork.notifyActionStart()
377
- const res = await ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId })
378
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
379
- return wrapResponse(buildActionExecutionResult({
648
+ const res = await measure('dispatch_ms', () => ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId, timeoutMs: actionTimeoutMs }))
649
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
650
+ const result = buildActionExecutionResult({
380
651
  actionType: 'swipe',
381
652
  sourceModule: 'server',
382
653
  selector: { x1, y1, x2, y2, duration },
@@ -384,21 +655,23 @@ async function handleSwipe(args: ToolCallArgs) {
384
655
  uiFingerprintBefore,
385
656
  uiFingerprintAfter,
386
657
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
387
- }))
658
+ })
659
+ return wrapResponse(result)
388
660
  }
389
661
 
390
- async function handleScrollToElement(args: ToolCallArgs) {
662
+ async function executeScrollToElement(args: ToolCallArgs) {
391
663
  const platform = requireStringArg(args, 'platform') as PlatformArg
392
664
  const selector = requireObjectArg<ScrollSelectorArg>(args, 'selector')
393
665
  const direction = getStringArg(args, 'direction') as 'down' | 'up' | undefined
394
666
  const maxScrolls = getNumberArg(args, 'maxScrolls')
395
667
  const scrollAmount = getNumberArg(args, 'scrollAmount')
396
668
  const deviceId = getStringArg(args, 'deviceId')
397
- const uiFingerprintBefore = await captureActionFingerprint(platform, deviceId)
669
+ const { verificationMode } = interactionControls(args)
670
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
398
671
  ToolsNetwork.notifyActionStart()
399
- const res = await ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId })
400
- const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
401
- return wrapResponse(buildActionExecutionResult({
672
+ const res = await measure('dispatch_ms', () => ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId }))
673
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
674
+ const result = buildActionExecutionResult({
402
675
  actionType: 'scroll_to_element',
403
676
  sourceModule: 'server',
404
677
  selector: selector ?? null,
@@ -415,17 +688,19 @@ async function handleScrollToElement(args: ToolCallArgs) {
415
688
  uiFingerprintBefore,
416
689
  uiFingerprintAfter,
417
690
  failure: res.success ? undefined : inferScrollFailure((res as any).reason)
418
- }))
691
+ })
692
+ return wrapResponse(result)
419
693
  }
420
694
 
421
- async function handleTypeText(args: ToolCallArgs) {
695
+ async function executeTypeText(args: ToolCallArgs) {
422
696
  const text = requireStringArg(args, 'text')
423
697
  const deviceId = getStringArg(args, 'deviceId')
424
- const uiFingerprintBefore = await captureActionFingerprint('android', deviceId)
698
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
699
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
425
700
  ToolsNetwork.notifyActionStart()
426
- const res = await ToolsInteract.typeTextHandler({ text, deviceId })
427
- const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
428
- return wrapResponse(buildActionExecutionResult({
701
+ const res = await measure('dispatch_ms', () => ToolsInteract.typeTextHandler({ text, deviceId, timeoutMs: actionTimeoutMs }))
702
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
703
+ const result = buildActionExecutionResult({
429
704
  actionType: 'type_text',
430
705
  sourceModule: 'server',
431
706
  selector: { text },
@@ -433,16 +708,18 @@ async function handleTypeText(args: ToolCallArgs) {
433
708
  uiFingerprintBefore,
434
709
  uiFingerprintAfter,
435
710
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
436
- }))
711
+ })
712
+ return wrapResponse(result)
437
713
  }
438
714
 
439
- async function handlePressBack(args: ToolCallArgs) {
715
+ async function executePressBack(args: ToolCallArgs) {
440
716
  const deviceId = getStringArg(args, 'deviceId')
441
- const uiFingerprintBefore = await captureActionFingerprint('android', deviceId)
717
+ const { verificationMode, actionTimeoutMs } = interactionControls(args)
718
+ const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
442
719
  ToolsNetwork.notifyActionStart()
443
- const res = await ToolsInteract.pressBackHandler({ deviceId })
444
- const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
445
- return wrapResponse(buildActionExecutionResult({
720
+ const res = await measure('dispatch_ms', () => ToolsInteract.pressBackHandler({ deviceId, timeoutMs: actionTimeoutMs }))
721
+ const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
722
+ const result = buildActionExecutionResult({
446
723
  actionType: 'press_back',
447
724
  sourceModule: 'server',
448
725
  selector: { key: 'back' },
@@ -450,7 +727,8 @@ async function handlePressBack(args: ToolCallArgs) {
450
727
  uiFingerprintBefore,
451
728
  uiFingerprintAfter,
452
729
  failure: res.success ? undefined : inferGenericFailure((res as any).error)
453
- }))
730
+ })
731
+ return wrapResponse(result)
454
732
  }
455
733
 
456
734
  async function handleStartLogStream(args: ToolCallArgs) {
@@ -528,6 +806,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
528
806
  find_element: handleFindElement,
529
807
  tap: handleTap,
530
808
  tap_element: handleTapElement,
809
+ run_journey: handleRunJourney,
531
810
  swipe: handleSwipe,
532
811
  scroll_to_element: handleScrollToElement,
533
812
  type_text: handleTypeText,
@@ -543,10 +822,26 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
543
822
  const handler = toolHandlers[name]
544
823
  if (!handler) throw new Error(`Unknown tool: ${name}`)
545
824
 
546
- try {
547
- return await handler(args)
548
- } catch (error) {
549
- console.error(`Error executing tool ${name}:`, error)
550
- return wrapToolError(name, error)
825
+ const started = performance.now()
826
+ const measured: Record<string, number> = {}
827
+ const addTiming = (response: ToolCallResult) => {
828
+ 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
829
+ const item = response.content.find(item => item.type === 'text')
830
+ if (item?.text) {
831
+ try {
832
+ const value = JSON.parse(item.text)
833
+ value.timing = { ...measured, ...value.timing, total_ms: performance.now() - started }
834
+ item.text = JSON.stringify(value)
835
+ } catch { /* Non-JSON text responses retain their original content. */ }
836
+ }
837
+ return response
551
838
  }
839
+ return withTiming(measured, async () => {
840
+ try {
841
+ return addTiming(await handler(args))
842
+ } catch (error) {
843
+ console.error(`Error executing tool ${name}:`, error)
844
+ return addTiming(wrapToolError(name, error))
845
+ }
846
+ })
552
847
  }
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
16
16
 
17
17
  export const serverInfo = {
18
18
  name: 'mobile-debug-mcp',
19
- version: '0.31.0'
19
+ version: '0.32.1'
20
20
  }
21
21
 
22
22
  export function createServer() {