mobile-debug-mcp 0.26.5 → 0.27.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.
@@ -1,10 +1,15 @@
1
1
  import type {
2
2
  ActionExecutionResult,
3
3
  ActionFailureCode,
4
- ActionTargetResolved
4
+ ActionTargetResolved,
5
+ FailureClass,
6
+ RecoveryState
5
7
  } from '../types.js'
6
8
  import { ToolsObserve } from '../observe/index.js'
7
9
 
10
+ export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
11
+ export const DEFAULT_MAX_RETRY_DEPTH = 3
12
+
8
13
  export function wrapResponse<T>(data: T) {
9
14
  return {
10
15
  content: [{
@@ -103,6 +108,7 @@ export function normalizeResolvedTarget(value: Partial<ActionTargetResolved> | n
103
108
 
104
109
  export function inferGenericFailure(message: string | undefined): { failureCode: ActionFailureCode; retryable: boolean } {
105
110
  if (message && /timeout/i.test(message)) return { failureCode: 'TIMEOUT', retryable: true }
111
+ if (message && /semantic mismatch/i.test(message)) return { failureCode: 'SEMANTIC_MISMATCH', retryable: false }
106
112
  return { failureCode: 'UNKNOWN', retryable: false }
107
113
  }
108
114
 
@@ -129,6 +135,42 @@ export function determineActionLifecycleState({
129
135
  return ACTION_LIFECYCLE_STATE_BY_OUTCOME.success
130
136
  }
131
137
 
138
+ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
139
+ switch (code) {
140
+ case 'ELEMENT_NOT_FOUND':
141
+ case 'AMBIGUOUS_TARGET':
142
+ case 'STALE_REFERENCE':
143
+ return 'TargetResolutionFailure'
144
+ case 'ELEMENT_NOT_INTERACTABLE':
145
+ return 'ExecutionFailure'
146
+ case 'TIMEOUT':
147
+ case 'ACTION_REJECTED':
148
+ case 'NAVIGATION_NO_CHANGE':
149
+ case 'UNKNOWN':
150
+ return 'ExecutionFailure'
151
+ case 'VERIFICATION_FAILED':
152
+ case 'EXPECT_STATE_MISMATCH':
153
+ return 'VerificationFailure'
154
+ case 'CONTROL_CONVERGENCE_FAILED':
155
+ return 'ControlConvergenceFailure'
156
+ case 'SEMANTIC_MISMATCH':
157
+ return 'SemanticMismatchFailure'
158
+ }
159
+ }
160
+
161
+ function buildRecoveryState(failureCode: ActionFailureCode, retryable: boolean): RecoveryState {
162
+ return {
163
+ failure_class: mapFailureCodeToFailureClass(failureCode),
164
+ runtime_code: failureCode,
165
+ recovery_attempts: 0,
166
+ max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
167
+ retry_depth: 0,
168
+ max_retry_depth: DEFAULT_MAX_RETRY_DEPTH,
169
+ is_terminal: false,
170
+ retry_allowed: retryable
171
+ }
172
+ }
173
+
132
174
  export function buildActionExecutionResult({
133
175
  actionType,
134
176
  device,
@@ -167,6 +209,7 @@ export function buildActionExecutionResult({
167
209
  },
168
210
  success,
169
211
  ...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
212
+ ...(failure ? { recovery: buildRecoveryState(failure.failureCode, failure.retryable) } : {}),
170
213
  ui_fingerprint_before: uiFingerprintBefore,
171
214
  ui_fingerprint_after: uiFingerprintAfter,
172
215
  ...(details ? { details } : {})
@@ -13,7 +13,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
13
13
 
14
14
  export const serverInfo = {
15
15
  name: 'mobile-debug-mcp',
16
- version: '0.26.5'
16
+ version: '0.27.0'
17
17
  }
18
18
 
19
19
  export function createServer() {
package/src/types.ts CHANGED
@@ -113,6 +113,20 @@ export interface UIElementSemanticMetadata {
113
113
  state_shape?: 'continuous' | 'discrete' | 'semantic' | null;
114
114
  }
115
115
 
116
+ export type FailureClass =
117
+ | 'TargetResolutionFailure'
118
+ | 'ExecutionFailure'
119
+ | 'VerificationFailure'
120
+ | 'ControlConvergenceFailure'
121
+ | 'SemanticMismatchFailure';
122
+
123
+ export type RecoveryStrategy =
124
+ | 're_resolve'
125
+ | 'alternate_candidate'
126
+ | 'state_refresh'
127
+ | 'retry_adjustment'
128
+ | 'step_back';
129
+
116
130
  export interface LoadingState {
117
131
  active: boolean;
118
132
  signal: string;
@@ -240,8 +254,25 @@ export type ActionFailureCode =
240
254
  | 'NAVIGATION_NO_CHANGE'
241
255
  | 'AMBIGUOUS_TARGET'
242
256
  | 'STALE_REFERENCE'
257
+ | 'ACTION_REJECTED'
258
+ | 'VERIFICATION_FAILED'
259
+ | 'EXPECT_STATE_MISMATCH'
260
+ | 'CONTROL_CONVERGENCE_FAILED'
261
+ | 'SEMANTIC_MISMATCH'
243
262
  | 'UNKNOWN'
244
263
 
264
+ export interface RecoveryState {
265
+ failure_class: FailureClass;
266
+ runtime_code: ActionFailureCode;
267
+ recovery_strategy?: RecoveryStrategy;
268
+ recovery_attempts: number;
269
+ max_recovery_attempts: number;
270
+ retry_depth: number;
271
+ max_retry_depth: number;
272
+ is_terminal: boolean;
273
+ retry_allowed?: boolean;
274
+ }
275
+
245
276
  export interface ActionTargetResolved {
246
277
  elementId: string | null;
247
278
  text: string | null;
@@ -345,6 +376,7 @@ export interface ActionExecutionResult {
345
376
  success: boolean;
346
377
  failure_code?: ActionFailureCode;
347
378
  retryable?: boolean;
379
+ recovery?: RecoveryState;
348
380
  ui_fingerprint_before?: string | null;
349
381
  ui_fingerprint_after?: string | null;
350
382
  details?: Record<string, unknown>;
@@ -415,6 +447,10 @@ export interface ExpectStateResponse {
415
447
  reason?: string;
416
448
  failure_code?: 'ELEMENT_NOT_FOUND' | 'UNKNOWN';
417
449
  retryable?: boolean;
450
+ stabilization_attempts?: number;
451
+ stabilization_window_ms?: number;
452
+ stable_observation_count?: number;
453
+ snapshot_freshness_ms?: number;
418
454
  }
419
455
 
420
456
  export interface AdjustControlResponse extends ActionExecutionResult {
@@ -346,9 +346,85 @@ async function run() {
346
346
  assert.strictEqual(cachedResolveAdjust.success, true)
347
347
  assert.strictEqual(cachedResolveAdjust.converged, true)
348
348
  assert.strictEqual(cachedResolveAdjust.within_tolerance, true)
349
- assert.strictEqual(cachedResolveAdjust.attempts, 3)
349
+ assert.ok(cachedResolveAdjust.attempts >= 3)
350
350
  assert.strictEqual(treeFetches, 1, 'second attempt should reuse the resolved element instead of refetching the UI tree')
351
351
 
352
+ const probeTapStart = tapCalls.length
353
+ const probeSwipeStart = swipeCalls.length
354
+ let probeVerificationCount = 0
355
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
356
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
357
+ screen: '',
358
+ resolution: { width: 1080, height: 2400 },
359
+ elements: [
360
+ {
361
+ text: 'Duration',
362
+ type: 'android.widget.SeekBar',
363
+ contentDescription: null,
364
+ clickable: true,
365
+ enabled: true,
366
+ visible: true,
367
+ bounds: [0, 0, 200, 40],
368
+ resourceId: 'seek_duration',
369
+ state: {
370
+ value: 10,
371
+ raw_value: 10,
372
+ value_range: { min: 0, max: 20 }
373
+ }
374
+ }
375
+ ]
376
+ })
377
+
378
+ ;(ToolsInteract as any).tapHandler = async ({ platform, x, y, deviceId }: any) => {
379
+ tapCalls.push({ platform, x, y, deviceId })
380
+ return {
381
+ device: { platform: platform || 'android', id: deviceId || 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
382
+ success: true,
383
+ x,
384
+ y
385
+ }
386
+ }
387
+
388
+ ;(ToolsInteract as any).expectStateHandler = async () => {
389
+ probeVerificationCount++
390
+ const value = probeVerificationCount === 1 ? 11 : 12
391
+ return {
392
+ success: true,
393
+ selector: { text: 'Duration' },
394
+ element_id: wait.element.elementId,
395
+ expected_state: { property: 'value', expected: 12 },
396
+ element: {
397
+ elementId: wait.element.elementId,
398
+ text: 'Duration',
399
+ resource_id: 'seek_duration',
400
+ accessibility_id: null,
401
+ class: 'android.widget.SeekBar',
402
+ bounds: [0, 0, 200, 40],
403
+ index: 0,
404
+ state: { value, raw_value: value, value_range: { min: 0, max: 20 } }
405
+ },
406
+ observed_state: { property: 'value', value, raw_value: value },
407
+ reason: value === 12 ? 'value matches expected value' : 'value still below target'
408
+ }
409
+ }
410
+
411
+ const probeAdjust = await ToolsInteract.adjustControlHandler({
412
+ element_id: wait.element.elementId,
413
+ property: 'value',
414
+ targetValue: 12,
415
+ tolerance: 0.5,
416
+ maxAttempts: 3,
417
+ platform: 'android'
418
+ })
419
+
420
+ assert.strictEqual(probeAdjust.success, true)
421
+ assert.strictEqual(probeAdjust.converged, true)
422
+ assert.strictEqual(probeAdjust.within_tolerance, true)
423
+ assert.strictEqual(probeAdjust.adjustment_mode, 'coordinate')
424
+ assert.strictEqual(probeAdjust.attempts, 2)
425
+ assert.strictEqual(tapCalls.length, probeTapStart + 2)
426
+ assert.strictEqual(swipeCalls.length, probeSwipeStart)
427
+
352
428
  console.log('adjust_control unit tests passed')
353
429
  } finally {
354
430
  ;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
@@ -0,0 +1,94 @@
1
+ import assert from 'assert'
2
+ import { ToolsInteract } from '../../../src/interact/index.js'
3
+ import * as Observe from '../../../src/observe/index.js'
4
+
5
+ async function run() {
6
+ console.log('Starting verification stabilization unit tests...')
7
+
8
+ const originalGetUITreeHandler = (Observe as any).ToolsObserve.getUITreeHandler
9
+
10
+ try {
11
+ let visibilityCalls = 0
12
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
13
+ visibilityCalls++
14
+ const visible = visibilityCalls >= 3
15
+ return {
16
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
17
+ captured_at_ms: Date.now(),
18
+ resolution: { width: 1080, height: 2400 },
19
+ elements: [
20
+ {
21
+ text: visible ? 'Ready' : 'Loading',
22
+ contentDescription: null,
23
+ type: 'android.widget.TextView',
24
+ resourceId: 'ready_label',
25
+ clickable: false,
26
+ enabled: true,
27
+ visible,
28
+ bounds: [0, 0, 120, 40]
29
+ }
30
+ ]
31
+ }
32
+ }
33
+
34
+ const visibleResult = await ToolsInteract.waitForUIHandler({
35
+ selector: { text: 'Ready' },
36
+ condition: 'visible',
37
+ timeout_ms: 600,
38
+ poll_interval_ms: 50,
39
+ platform: 'android'
40
+ })
41
+
42
+ assert.strictEqual(visibleResult.status, 'success')
43
+ assert.ok(visibilityCalls >= 4, 'visibility should be confirmed across consecutive reads')
44
+
45
+ let stateCalls = 0
46
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
47
+ stateCalls++
48
+ const value = stateCalls >= 3 ? 30 : 10
49
+ const stale = stateCalls === 2
50
+ return {
51
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
52
+ captured_at_ms: stale ? Date.now() - 1000 : Date.now(),
53
+ resolution: { width: 1080, height: 2400 },
54
+ elements: [
55
+ {
56
+ text: 'Volume',
57
+ contentDescription: null,
58
+ type: 'android.widget.SeekBar',
59
+ resourceId: 'volume_slider',
60
+ clickable: true,
61
+ enabled: true,
62
+ visible: true,
63
+ bounds: [0, 0, 240, 40],
64
+ state: {
65
+ value,
66
+ raw_value: value,
67
+ value_range: { min: 0, max: 100 }
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ }
73
+
74
+ const stateResult = await ToolsInteract.expectStateHandler({
75
+ selector: { text: 'Volume' },
76
+ property: 'value',
77
+ expected: 30,
78
+ platform: 'android'
79
+ })
80
+
81
+ assert.strictEqual(stateResult.success, true)
82
+ assert.strictEqual(stateResult.reason, 'value matches expected value')
83
+ assert.ok(stateCalls >= 4, 'state verification should require stable fresh reads')
84
+
85
+ console.log('verification stabilization unit tests passed')
86
+ } finally {
87
+ ;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
88
+ }
89
+ }
90
+
91
+ run().catch((error) => {
92
+ console.error(error)
93
+ process.exit(1)
94
+ })
@@ -1,5 +1,5 @@
1
1
  import assert from 'assert'
2
- import { requireBooleanArg } from '../../../src/server/common.js'
2
+ import { buildActionExecutionResult, inferGenericFailure, requireBooleanArg } from '../../../src/server/common.js'
3
3
 
4
4
  function run() {
5
5
  assert.strictEqual(requireBooleanArg({ exact: true }, 'exact'), true)
@@ -7,6 +7,41 @@ function run() {
7
7
  assert.throws(() => requireBooleanArg({}, 'exact'), /Missing or invalid boolean argument: exact/)
8
8
  assert.throws(() => requireBooleanArg({ exact: 'true' as unknown as boolean }, 'exact'), /Missing or invalid boolean argument: exact/)
9
9
 
10
+ assert.deepStrictEqual(inferGenericFailure('semantic mismatch between inferred and raw state'), {
11
+ failureCode: 'SEMANTIC_MISMATCH',
12
+ retryable: false
13
+ })
14
+
15
+ const recoveryResult = buildActionExecutionResult({
16
+ actionType: 'tap',
17
+ sourceModule: 'server',
18
+ selector: { x: 10, y: 20 },
19
+ success: false,
20
+ uiFingerprintBefore: 'fp_before',
21
+ uiFingerprintAfter: 'fp_after',
22
+ failure: { failureCode: 'SEMANTIC_MISMATCH', retryable: false }
23
+ })
24
+ assert.strictEqual(recoveryResult.failure_code, 'SEMANTIC_MISMATCH')
25
+ assert.strictEqual(recoveryResult.recovery?.failure_class, 'SemanticMismatchFailure')
26
+ assert.strictEqual(recoveryResult.recovery?.runtime_code, 'SEMANTIC_MISMATCH')
27
+ assert.strictEqual(recoveryResult.recovery?.retry_allowed, false)
28
+ assert.strictEqual(recoveryResult.recovery?.max_recovery_attempts, 3)
29
+ assert.strictEqual(recoveryResult.recovery?.max_retry_depth, 3)
30
+
31
+ const notInteractableResult = buildActionExecutionResult({
32
+ actionType: 'tap',
33
+ sourceModule: 'server',
34
+ selector: { x: 5, y: 5 },
35
+ success: false,
36
+ uiFingerprintBefore: 'fp_before',
37
+ uiFingerprintAfter: 'fp_after',
38
+ failure: { failureCode: 'ELEMENT_NOT_INTERACTABLE', retryable: true }
39
+ })
40
+ assert.strictEqual(notInteractableResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
41
+ assert.strictEqual(notInteractableResult.recovery?.failure_class, 'ExecutionFailure')
42
+ assert.strictEqual(notInteractableResult.recovery?.runtime_code, 'ELEMENT_NOT_INTERACTABLE')
43
+ assert.strictEqual(notInteractableResult.recovery?.retry_allowed, true)
44
+
10
45
  console.log('server common tests passed')
11
46
  }
12
47