mobile-debug-mcp 0.30.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.
@@ -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,6 +393,8 @@ 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
399
  stability_window_ms: { type: 'number', description: 'How long the change must remain stable before success (default 300)', default: 300 }
395
400
  }
@@ -321,7 +321,9 @@ async function handleWaitForUIChange(args: ToolCallArgs) {
321
321
  const timeout_ms = getNumberArg(args, 'timeout_ms') ?? 60000
322
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.30.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: []
@@ -6,7 +6,18 @@ type UiTree = {
6
6
  device: { platform: 'android', id: string, osVersion: string, model: string, simulator: boolean }
7
7
  screen: string
8
8
  resolution: { width: number, height: number }
9
- elements: Array<{ text: string, type: string, bounds: number[], visible: boolean }>
9
+ elements: Array<{
10
+ text: string
11
+ type: string
12
+ bounds: number[]
13
+ visible: boolean
14
+ enabled?: boolean
15
+ clickable?: boolean
16
+ stable_id?: string
17
+ parentId?: number
18
+ children?: number[]
19
+ state?: Record<string, unknown> | null
20
+ }>
10
21
  snapshot_revision: number
11
22
  captured_at_ms: number
12
23
  }
@@ -22,6 +33,21 @@ function makeTree(screen: string, revision: number): UiTree {
22
33
  }
23
34
  }
24
35
 
36
+ function makeScopedTree(title: string, status: string, revision: number): UiTree {
37
+ return {
38
+ device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
39
+ screen: 'Scoped',
40
+ resolution: { width: 1080, height: 2400 },
41
+ elements: [
42
+ { text: 'Root', type: 'FrameLayout', bounds: [0, 0, 1080, 2400], visible: true, enabled: true, stable_id: 'root', children: [1, 2] },
43
+ { text: title, type: 'TextView', bounds: [0, 0, 1080, 200], visible: true, enabled: true, stable_id: 'title', parentId: 0, state: { text_value: title } },
44
+ { text: status, type: 'TextView', bounds: [0, 200, 1080, 260], visible: true, enabled: true, stable_id: 'status', parentId: 0, state: { text_value: status } }
45
+ ],
46
+ snapshot_revision: revision,
47
+ captured_at_ms: 1000 + revision
48
+ }
49
+ }
50
+
25
51
  async function runScenario({
26
52
  snapshots,
27
53
  expectedChange,
@@ -72,6 +98,62 @@ async function runScenario({
72
98
  }
73
99
  }
74
100
 
101
+ async function runScopedScenario() {
102
+ const snapshots = [
103
+ makeScopedTree('Title', 'Status 1', 1),
104
+ makeScopedTree('Title', 'Status 2', 2),
105
+ makeScopedTree('Title Ready', 'Status 2', 3),
106
+ makeScopedTree('Title Ready', 'Status 2', 4)
107
+ ]
108
+
109
+ const originalGetUITreeHandler = (ToolsObserve as any).getUITreeHandler
110
+ const originalSetTimeout = globalThis.setTimeout
111
+ const originalDateNow = Date.now
112
+ let now = 0
113
+ let calls = 0
114
+
115
+ try {
116
+ ;(Date as any).now = () => now
117
+ ;(globalThis as any).setTimeout = (callback: (...args: any[]) => void) => {
118
+ now += 1
119
+ callback()
120
+ return 0
121
+ }
122
+
123
+ ;(ToolsObserve as any).getUITreeHandler = async () => snapshots[Math.min(calls++, snapshots.length - 1)]
124
+
125
+ const resolution = await ToolsInteract.waitForUIHandler({
126
+ selector: { text: 'Title' },
127
+ condition: 'exists',
128
+ timeout_ms: 1000,
129
+ poll_interval_ms: 1,
130
+ platform: 'android',
131
+ deviceId: 'mock'
132
+ })
133
+
134
+ const targetId = resolution?.element?.elementId
135
+ assert.ok(targetId)
136
+
137
+ calls = 0
138
+ now = 0
139
+ const result = await ToolsInteract.waitForUIChangeHandler({
140
+ platform: 'android',
141
+ deviceId: 'mock',
142
+ expected_change: 'text_change',
143
+ scope: 'subtree',
144
+ target: targetId,
145
+ timeout_ms: 2000,
146
+ stability_window_ms: 1
147
+ })
148
+
149
+ return { result, targetId, calls }
150
+ } finally {
151
+ ;(ToolsObserve as any).getUITreeHandler = originalGetUITreeHandler
152
+ ;(globalThis as any).setTimeout = originalSetTimeout
153
+ ;(Date as any).now = originalDateNow
154
+ }
155
+ }
156
+
75
157
  async function run() {
76
158
  const success = await runScenario({
77
159
  snapshots: [makeTree('Loading', 1), makeTree('Loaded', 2)],
@@ -109,6 +191,15 @@ async function run() {
109
191
  assert.strictEqual(defaultWindow.calls, 4)
110
192
  assert.deepStrictEqual(defaultWindow.delays, [300, 300, 300])
111
193
 
194
+ const scoped = await runScopedScenario()
195
+ assert.strictEqual(scoped.result.success, true)
196
+ assert.strictEqual(scoped.result.scope, 'subtree')
197
+ assert.strictEqual(scoped.result.target, scoped.targetId)
198
+ assert.strictEqual(scoped.result.observed_change, 'text_change')
199
+ assert.strictEqual(scoped.result.change_summary?.added_elements, 0)
200
+ assert.strictEqual(scoped.result.change_summary?.removed_elements, 0)
201
+ assert.strictEqual(scoped.result.stability_state, 'stable')
202
+
112
203
  const resetWindow = await runScenario({
113
204
  snapshots: [makeTree('Loading', 1), makeTree('Loaded', 2), makeTree('Loaded-again', 3), makeTree('Loaded-again', 4), makeTree('Loaded-again', 5)],
114
205
  expectedChange: 'text_change',
@@ -0,0 +1,67 @@
1
+ import assert from 'assert'
2
+ import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
3
+
4
+ async function run() {
5
+ console.log('Starting snapshot_metadata unit tests...')
6
+
7
+ resetSnapshotMetadataForTests()
8
+
9
+ const deviceKey = 'android:mock'
10
+ const first = deriveSnapshotMetadata(deviceKey, {
11
+ screen: 'Home',
12
+ resolution: { width: 100, height: 200 },
13
+ elements: [
14
+ {
15
+ text: 'Alpha',
16
+ contentDescription: null,
17
+ resourceId: 'row_1',
18
+ type: 'TextView',
19
+ clickable: false,
20
+ enabled: true,
21
+ visible: true,
22
+ bounds: [0, 0, 10, 10],
23
+ state: null,
24
+ stable_id: 'stable-row'
25
+ }
26
+ ]
27
+ }, 'ui_tree')
28
+
29
+ assert.strictEqual(first.snapshot_revision, 1)
30
+ assert.strictEqual(first.snapshot_delta, null)
31
+
32
+ const second = deriveSnapshotMetadata(deviceKey, {
33
+ screen: 'Home',
34
+ resolution: { width: 100, height: 200 },
35
+ elements: [
36
+ {
37
+ text: 'Beta',
38
+ contentDescription: null,
39
+ resourceId: 'row_1',
40
+ type: 'TextView',
41
+ clickable: false,
42
+ enabled: true,
43
+ visible: true,
44
+ bounds: [0, 0, 10, 10],
45
+ state: null,
46
+ stable_id: 'stable-row'
47
+ }
48
+ ]
49
+ }, 'ui_tree')
50
+
51
+ assert.strictEqual(second.snapshot_revision, 2)
52
+ assert.deepStrictEqual(second.snapshot_delta, {
53
+ previous_snapshot_revision: 1,
54
+ added_elements: 0,
55
+ removed_elements: 0,
56
+ mutated_elements: 1,
57
+ total_elements: 1
58
+ })
59
+
60
+ resetSnapshotMetadataForTests()
61
+ console.log('snapshot_metadata unit tests passed')
62
+ }
63
+
64
+ run().catch((error) => {
65
+ console.error(error)
66
+ process.exit(1)
67
+ })