mobile-debug-mcp 0.29.0 → 0.30.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.
@@ -1,9 +1,10 @@
1
1
  import crypto from 'crypto'
2
- import type { GetUITreeResponse, LoadingState, UIElement } from '../types.js'
2
+ import type { GetUITreeResponse, LoadingState, SnapshotDelta, UIElement } from '../types.js'
3
3
 
4
4
  interface SnapshotState {
5
5
  revision: number
6
6
  signature: string | null
7
+ elementSignatures: Map<string, string>
7
8
  }
8
9
 
9
10
  const snapshotStateByDevice = new Map<string, SnapshotState>()
@@ -38,6 +39,63 @@ function stableElementSignature(element: UIElement) {
38
39
  }
39
40
  }
40
41
 
42
+ function stableElementIdentity(element: UIElement, index: number) {
43
+ const stableId = normalize(element.stable_id)
44
+ if (stableId) return `stable:${stableId}`
45
+
46
+ return `fallback:${crypto.createHash('sha1').update(JSON.stringify({
47
+ text: normalize(element.text),
48
+ contentDescription: normalize(element.contentDescription),
49
+ resourceId: normalize(element.resourceId),
50
+ type: normalize(element.type),
51
+ bounds: normalizeBounds(element.bounds),
52
+ index
53
+ })).digest('hex')}`
54
+ }
55
+
56
+ function buildElementSignatures(tree: Pick<GetUITreeResponse, 'elements'> | null | undefined) {
57
+ const signatures = new Map<string, string>()
58
+ const elements = Array.isArray(tree?.elements) ? tree!.elements! : []
59
+
60
+ for (let index = 0; index < elements.length; index++) {
61
+ const element = elements[index]
62
+ if (!element) continue
63
+ const identity = stableElementIdentity(element, index)
64
+ signatures.set(identity, crypto.createHash('sha1').update(JSON.stringify(stableElementSignature(element))).digest('hex'))
65
+ }
66
+
67
+ return signatures
68
+ }
69
+
70
+ function summarizeSnapshotDelta(previous: SnapshotState | undefined, currentElements: Map<string, string>): SnapshotDelta | null {
71
+ if (!previous) return null
72
+
73
+ let added = 0
74
+ let removed = 0
75
+ let mutated = 0
76
+
77
+ for (const [identity, signature] of currentElements.entries()) {
78
+ const previousSignature = previous.elementSignatures.get(identity)
79
+ if (previousSignature === undefined) {
80
+ added++
81
+ } else if (previousSignature !== signature) {
82
+ mutated++
83
+ }
84
+ }
85
+
86
+ for (const identity of previous.elementSignatures.keys()) {
87
+ if (!currentElements.has(identity)) removed++
88
+ }
89
+
90
+ return {
91
+ previous_snapshot_revision: previous.revision,
92
+ added_elements: added,
93
+ removed_elements: removed,
94
+ mutated_elements: mutated,
95
+ total_elements: currentElements.size
96
+ }
97
+ }
98
+
41
99
  export function computeSnapshotSignature(tree: Pick<GetUITreeResponse, 'elements' | 'screen' | 'resolution' | 'error'> | null | undefined): string | null {
42
100
  if (!tree || tree.error) return null
43
101
 
@@ -83,6 +141,10 @@ export function deriveSnapshotMetadata(
83
141
  ) {
84
142
  const signature = signatureOverride ?? computeSnapshotSignature(tree)
85
143
  const previous = snapshotStateByDevice.get(deviceKey)
144
+ const hasValidTree = !!tree && !tree.error
145
+ const currentElementSignatures = hasValidTree
146
+ ? buildElementSignatures(tree)
147
+ : previous?.elementSignatures ?? new Map<string, string>()
86
148
 
87
149
  let revision = 1
88
150
  if (previous) {
@@ -93,11 +155,16 @@ export function deriveSnapshotMetadata(
93
155
  }
94
156
  }
95
157
 
96
- snapshotStateByDevice.set(deviceKey, { revision, signature })
158
+ snapshotStateByDevice.set(deviceKey, {
159
+ revision,
160
+ signature,
161
+ elementSignatures: currentElementSignatures
162
+ })
97
163
 
98
164
  return {
99
165
  snapshot_revision: revision,
100
166
  captured_at_ms: Date.now(),
167
+ snapshot_delta: hasValidTree ? summarizeSnapshotDelta(previous, currentElementSignatures) : null,
101
168
  loading_state: detectLoadingState(tree, source)
102
169
  }
103
170
  }
@@ -244,7 +244,7 @@ Failure Handling:
244
244
  },
245
245
  {
246
246
  name: 'capture_debug_snapshot',
247
- description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON with snapshot_revision, captured_at_ms, and loading_state when detectable.',
247
+ description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON with snapshot_revision, captured_at_ms, snapshot_delta, and loading_state when detectable.',
248
248
  inputSchema: {
249
249
  type: 'object',
250
250
  properties: {
@@ -295,7 +295,7 @@ Failure Handling:
295
295
  },
296
296
  {
297
297
  name: 'get_ui_tree',
298
- description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content with snapshot metadata when available.',
298
+ description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content with snapshot metadata and incremental delta signals when available.',
299
299
  inputSchema: {
300
300
  type: 'object',
301
301
  properties: {
@@ -376,11 +376,14 @@ Inputs:
376
376
  - expected_change (optional): hierarchy_diff, text_change, or state_change
377
377
  - timeout_ms (optional)
378
378
  - stability_window_ms (optional)
379
+ - scope (optional): screen or subtree
380
+ - target (optional): element_id when scope=subtree
379
381
 
380
382
  Guidance:
381
383
  - Prefer wait_for_screen_change for navigation transitions.
382
384
  - Prefer wait_for_ui_change for in-place mutations and non-navigation updates.
383
385
  - Use the returned snapshot_revision as the observed synchronization point when available.
386
+ - Scoped waits return scope-aware stability metadata and a lightweight change summary.
384
387
 
385
388
  Failure Handling:
386
389
  - TIMEOUT means the UI did not change in a stable way within the allotted time.`,
@@ -390,8 +393,10 @@ Failure Handling:
390
393
  platform: { type: 'string', enum: ['android', 'ios'], description: 'Optional platform override (android|ios)' },
391
394
  deviceId: { type: 'string', description: 'Optional device id/udid to target' },
392
395
  expected_change: { type: 'string', enum: ['hierarchy_diff', 'text_change', 'state_change'], description: 'Optional type of UI change to wait for' },
396
+ scope: { type: 'string', enum: ['screen', 'subtree'], default: 'screen', description: 'Synchronization scope for the wait' },
397
+ target: { type: 'string', description: 'Target element_id when scope is subtree' },
393
398
  timeout_ms: { type: 'number', description: 'Timeout in ms to wait for change (default 60000)', default: 60000 },
394
- stability_window_ms: { type: 'number', description: 'How long the change must remain stable before success (default 250)', default: 250 }
399
+ stability_window_ms: { type: 'number', description: 'How long the change must remain stable before success (default 300)', default: 300 }
395
400
  }
396
401
  }
397
402
  },
@@ -319,9 +319,11 @@ async function handleWaitForUIChange(args: ToolCallArgs) {
319
319
  const platform = getStringArg(args, 'platform') as PlatformArg | undefined
320
320
  const deviceId = getStringArg(args, 'deviceId')
321
321
  const timeout_ms = getNumberArg(args, 'timeout_ms') ?? 60000
322
- const stability_window_ms = getNumberArg(args, 'stability_window_ms') ?? 250
322
+ const stability_window_ms = getNumberArg(args, 'stability_window_ms') ?? 300
323
323
  const expected_change = getStringArg(args, 'expected_change') as 'hierarchy_diff' | 'text_change' | 'state_change' | undefined
324
- const res = await ToolsInteract.waitForUIChangeHandler({ platform, deviceId, timeout_ms, stability_window_ms, expected_change })
324
+ const scope = getStringArg(args, 'scope') as 'screen' | 'subtree' | undefined
325
+ const target = getStringArg(args, 'target')
326
+ const res = await ToolsInteract.waitForUIChangeHandler({ platform, deviceId, timeout_ms, stability_window_ms, expected_change, scope, target })
325
327
  return wrapResponse(res)
326
328
  }
327
329
 
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
16
16
 
17
17
  export const serverInfo = {
18
18
  name: 'mobile-debug-mcp',
19
- version: '0.29.0'
19
+ version: '0.30.1'
20
20
  }
21
21
 
22
22
  export function createServer() {
package/src/types.ts CHANGED
@@ -152,6 +152,14 @@ export interface LoadingState {
152
152
  source: string;
153
153
  }
154
154
 
155
+ export interface SnapshotDelta {
156
+ previous_snapshot_revision: number | null;
157
+ added_elements: number;
158
+ removed_elements: number;
159
+ mutated_elements: number;
160
+ total_elements: number;
161
+ }
162
+
155
163
  export interface CaptureAndroidScreenResponse {
156
164
  device: DeviceInfo;
157
165
  screenshot: string; // base64 encoded string
@@ -207,6 +215,7 @@ export interface GetUITreeResponse {
207
215
  elements: UIElement[];
208
216
  snapshot_revision: number;
209
217
  captured_at_ms: number;
218
+ snapshot_delta?: SnapshotDelta | null;
210
219
  loading_state?: LoadingState | null;
211
220
  error?: string;
212
221
  }
@@ -231,6 +240,7 @@ export interface CaptureDebugSnapshotRawResponse {
231
240
  timestamp: number;
232
241
  snapshot_revision: number;
233
242
  captured_at_ms: number;
243
+ snapshot_delta?: SnapshotDelta | null;
234
244
  reason: string;
235
245
  activity: string | null;
236
246
  fingerprint: string | null;
@@ -497,11 +507,25 @@ export interface WaitForUIChangeResponse {
497
507
  success: boolean;
498
508
  observed_change: 'hierarchy_diff' | 'text_change' | 'state_change' | null;
499
509
  snapshot_revision?: number;
510
+ snapshot_freshness_ms?: number | null;
511
+ scope?: 'screen' | 'subtree';
512
+ target?: string | null;
513
+ stability_state?: 'transient' | 'stable';
514
+ change_summary?: {
515
+ total_elements: number;
516
+ added_elements: number;
517
+ removed_elements: number;
518
+ mutated_elements: number;
519
+ } | null;
500
520
  timeout: boolean;
501
521
  elapsed_ms: number;
502
522
  expected_change?: 'hierarchy_diff' | 'text_change' | 'state_change';
503
523
  reason?: string;
504
524
  loading_state?: LoadingState | null;
525
+ error?: {
526
+ code: 'INVALID_SCOPE' | 'ELEMENT_NOT_FOUND';
527
+ message: string;
528
+ };
505
529
  }
506
530
 
507
531
  export interface SwipeResponse {
@@ -263,6 +263,110 @@ async function run() {
263
263
  assert.strictEqual(swipeCalls.length, 0)
264
264
  assert.ok(tapCalls[3].x > 2750, 'wide, high-range control should not be clamped to a 3% endpoint margin')
265
265
 
266
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
267
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
268
+ screen: '',
269
+ resolution: { width: 1080, height: 2400 },
270
+ elements: [
271
+ {
272
+ text: 'Duration',
273
+ type: 'android.widget.SeekBar',
274
+ contentDescription: null,
275
+ clickable: true,
276
+ enabled: false,
277
+ visible: true,
278
+ bounds: [0, 0, 200, 40],
279
+ resourceId: 'seek_duration',
280
+ state: {
281
+ value: 10,
282
+ raw_value: 10,
283
+ value_range: { min: 0, max: 20 }
284
+ }
285
+ }
286
+ ]
287
+ })
288
+
289
+ const disabledAdjust = await ToolsInteract.adjustControlHandler({
290
+ element_id: wait.element.elementId,
291
+ property: 'value',
292
+ targetValue: 8,
293
+ tolerance: 0.5,
294
+ maxAttempts: 1,
295
+ platform: 'android'
296
+ })
297
+
298
+ assert.strictEqual(disabledAdjust.success, false)
299
+ assert.strictEqual(disabledAdjust.failure_code, 'ELEMENT_NOT_INTERACTABLE')
300
+
301
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
302
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
303
+ screen: '',
304
+ resolution: { width: 1080, height: 2400 },
305
+ elements: [
306
+ {
307
+ text: 'Stable slider',
308
+ type: 'android.widget.SeekBar',
309
+ contentDescription: null,
310
+ clickable: true,
311
+ enabled: true,
312
+ visible: true,
313
+ bounds: [0, 0, 200, 40],
314
+ resourceId: 'seek_stable',
315
+ stable_id: 'stable-1',
316
+ state: {
317
+ value: 4,
318
+ raw_value: 4,
319
+ value_range: { min: 0, max: 10 }
320
+ }
321
+ }
322
+ ]
323
+ })
324
+
325
+ const stableWait = await ToolsInteract.waitForUIHandler({
326
+ selector: { text: 'Stable slider' },
327
+ condition: 'clickable',
328
+ timeout_ms: 200,
329
+ poll_interval_ms: 50,
330
+ platform: 'android'
331
+ })
332
+ assert.strictEqual(stableWait.status, 'success')
333
+
334
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
335
+ device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
336
+ screen: '',
337
+ resolution: { width: 1080, height: 2400 },
338
+ elements: [
339
+ {
340
+ text: 'Stable slider',
341
+ type: 'android.widget.SeekBar',
342
+ contentDescription: null,
343
+ clickable: true,
344
+ enabled: true,
345
+ visible: true,
346
+ bounds: [0, 0, 200, 40],
347
+ resourceId: 'seek_stable',
348
+ stable_id: 'stable-2',
349
+ state: {
350
+ value: 4,
351
+ raw_value: 4,
352
+ value_range: { min: 0, max: 10 }
353
+ }
354
+ }
355
+ ]
356
+ })
357
+
358
+ const staleAdjust = await ToolsInteract.adjustControlHandler({
359
+ element_id: stableWait.element.elementId,
360
+ property: 'value',
361
+ targetValue: 6,
362
+ tolerance: 0.5,
363
+ maxAttempts: 1,
364
+ platform: 'android'
365
+ })
366
+
367
+ assert.strictEqual(staleAdjust.success, false)
368
+ assert.strictEqual(staleAdjust.failure_code, 'STALE_REFERENCE')
369
+
266
370
  let treeFetches = 0
267
371
  let retryVerificationCount = 0
268
372
  ;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
@@ -0,0 +1,24 @@
1
+ import assert from 'assert'
2
+ import { ToolsInteract } from '../../../src/interact/index.js'
3
+
4
+ async function run() {
5
+ console.log('Starting subtree_collection unit tests...')
6
+
7
+ const elements = [
8
+ { text: 'Root', resourceId: 'root-node', stable_id: 'root-stable', bounds: [0, 0, 100, 100], visible: true, enabled: true, clickable: true },
9
+ { text: 'Numeric child', parentId: '0', stable_id: 'numeric-child', bounds: [0, 0, 50, 50], visible: true, enabled: true, clickable: true },
10
+ { text: 'Resource child', parentId: 'root-node', stable_id: 'resource-child', bounds: [0, 50, 50, 100], visible: true, enabled: true, clickable: true },
11
+ { text: 'Stable child', parentId: 'root-stable', stable_id: 'stable-child', bounds: [50, 0, 100, 50], visible: true, enabled: true, clickable: true },
12
+ { text: 'Grandchild', parentId: 'resource-child', bounds: [50, 50, 100, 100], visible: true, enabled: true, clickable: true }
13
+ ] as any[]
14
+
15
+ const indices = (ToolsInteract as any)._collectSubtreeIndices(elements, 0)
16
+ assert.deepStrictEqual(indices, [0, 1, 2, 3, 4])
17
+
18
+ console.log('subtree_collection unit tests passed')
19
+ }
20
+
21
+ run().catch((error) => {
22
+ console.error(error)
23
+ process.exit(1)
24
+ })
@@ -84,6 +84,77 @@ async function run() {
84
84
  assert.strictEqual(disabledResult.success, false)
85
85
  assert.strictEqual(disabledResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
86
86
 
87
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
88
+ device: { platform: 'android', id: 'mock-device' },
89
+ elements: [
90
+ { text: 'Plain label', resourceId: 'txt_plain', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: false }
91
+ ]
92
+ })
93
+ const waitPlain = await ToolsInteract.waitForUIHandler({
94
+ selector: { text: 'Plain label' },
95
+ condition: 'exists',
96
+ timeout_ms: 200,
97
+ poll_interval_ms: 50,
98
+ platform: 'android'
99
+ })
100
+ const plainResult = await ToolsInteract.tapElementHandler({ elementId: waitPlain.element.elementId })
101
+ assert.strictEqual(plainResult.success, false)
102
+ assert.strictEqual(plainResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
103
+
104
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
105
+ device: { platform: 'ios', id: 'ios-mock' },
106
+ elements: [
107
+ {
108
+ text: 'Semantic tap',
109
+ resourceId: 'ios_semantic_tap',
110
+ bounds: [10, 10, 30, 30],
111
+ visible: true,
112
+ enabled: true,
113
+ clickable: false,
114
+ semantic: {
115
+ is_clickable: true,
116
+ is_container: false,
117
+ supported_actions: ['tap']
118
+ }
119
+ }
120
+ ]
121
+ })
122
+ const iosSemanticWait = await ToolsInteract.waitForUIHandler({
123
+ selector: { text: 'Semantic tap' },
124
+ condition: 'exists',
125
+ timeout_ms: 200,
126
+ poll_interval_ms: 50,
127
+ platform: 'ios'
128
+ })
129
+ const iosSemanticTap = await ToolsInteract.tapElementHandler({ elementId: iosSemanticWait.element.elementId })
130
+ assert.strictEqual(iosSemanticTap.success, true)
131
+ assert.strictEqual(iosSemanticTap.target.resolved?.elementId, iosSemanticWait.element.elementId)
132
+ assert.deepStrictEqual(tapped, { platform: 'ios', x: 20, y: 20, deviceId: 'ios-mock' })
133
+
134
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
135
+ device: { platform: 'android', id: 'mock-device' },
136
+ elements: [
137
+ { text: 'Stable target', resourceId: 'btn_stable', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: true, stable_id: 'stable-1' }
138
+ ]
139
+ })
140
+ const waitStable = await ToolsInteract.waitForUIHandler({
141
+ selector: { text: 'Stable target' },
142
+ condition: 'exists',
143
+ timeout_ms: 200,
144
+ poll_interval_ms: 50,
145
+ platform: 'android'
146
+ })
147
+
148
+ ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
149
+ device: { platform: 'android', id: 'mock-device' },
150
+ elements: [
151
+ { text: 'Stable target', resourceId: 'btn_stable', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: true, stable_id: 'stable-2' }
152
+ ]
153
+ })
154
+ const stableMismatchResult = await ToolsInteract.tapElementHandler({ elementId: waitStable.element.elementId })
155
+ assert.strictEqual(stableMismatchResult.success, false)
156
+ assert.strictEqual(stableMismatchResult.failure_code, 'STALE_REFERENCE')
157
+
87
158
  ;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
88
159
  device: { platform: 'android', id: 'mock-device' },
89
160
  elements: []