mobile-debug-mcp 0.32.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
  3. package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
  4. package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
  5. package/.claude-plugin/marketplace.json +14 -0
  6. package/.cursor-plugin/marketplace.json +16 -0
  7. package/.github/copilot-instructions.md +7 -0
  8. package/AGENTS.md +21 -10
  9. package/README.md +67 -5
  10. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  11. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  12. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  13. package/agent-plugin/.mcp.json +14 -0
  14. package/agent-plugin/LICENSE +201 -0
  15. package/agent-plugin/README.md +29 -0
  16. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  17. package/agent-plugin/mcp.json +11 -0
  18. package/agent-plugin/plugin.json +29 -0
  19. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
  20. package/dist/interact/android.js +10 -4
  21. package/dist/interact/conditional-action.js +68 -0
  22. package/dist/interact/index.js +54 -14
  23. package/dist/interact/ios.js +14 -8
  24. package/dist/manage/android.js +4 -3
  25. package/dist/manage/ios.js +3 -2
  26. package/dist/observe/android.js +27 -22
  27. package/dist/observe/evidence.js +273 -0
  28. package/dist/observe/index.js +33 -6
  29. package/dist/observe/ios.js +25 -6
  30. package/dist/observe/readiness-service.js +115 -0
  31. package/dist/observe/readiness.js +81 -0
  32. package/dist/observe/recording.js +165 -0
  33. package/dist/observe/snapshot-metadata.js +10 -2
  34. package/dist/server/common.js +9 -0
  35. package/dist/server/condition-schema.js +32 -0
  36. package/dist/server/journey-schema.js +3 -2
  37. package/dist/server/readiness-schema.js +12 -0
  38. package/dist/server/tool-definitions.js +46 -0
  39. package/dist/server/tool-handlers.js +99 -17
  40. package/dist/server-core.js +1 -1
  41. package/dist/utils/ios/utils.js +4 -4
  42. package/dist/utils/operation-budget.js +24 -2
  43. package/dist/utils/resolve-device.js +7 -0
  44. package/docs/CHANGELOG.md +16 -2
  45. package/docs/benchmarks/ui-tree-performance/README.md +4 -3
  46. package/docs/specs/020-conditional-actions/intent.md +62 -0
  47. package/docs/specs/020-conditional-actions/plan.md +214 -0
  48. package/docs/specs/020-conditional-actions/spec.md +301 -0
  49. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  50. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  51. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  52. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  53. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  54. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  55. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  56. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  57. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  58. package/docs/specs/process.md +213 -0
  59. package/docs/tools/TOOLS.md +3 -0
  60. package/docs/tools/evidence.md +79 -0
  61. package/docs/tools/interact.md +125 -2
  62. package/docs/tools/observe.md +64 -2
  63. package/package.json +4 -2
  64. package/scripts/agent-plugin.mjs +98 -0
  65. package/src/interact/android.ts +10 -4
  66. package/src/interact/conditional-action.ts +75 -0
  67. package/src/interact/index.ts +42 -14
  68. package/src/interact/ios.ts +14 -8
  69. package/src/manage/android.ts +4 -3
  70. package/src/manage/ios.ts +3 -2
  71. package/src/observe/android.ts +35 -21
  72. package/src/observe/evidence.ts +255 -0
  73. package/src/observe/index.ts +32 -8
  74. package/src/observe/ios.ts +32 -8
  75. package/src/observe/readiness-service.ts +89 -0
  76. package/src/observe/readiness.ts +78 -0
  77. package/src/observe/snapshot-metadata.ts +12 -1
  78. package/src/server/common.ts +7 -0
  79. package/src/server/condition-schema.ts +32 -0
  80. package/src/server/journey-schema.ts +3 -2
  81. package/src/server/readiness-schema.ts +12 -0
  82. package/src/server/tool-definitions.ts +44 -0
  83. package/src/server/tool-handlers.ts +93 -17
  84. package/src/server-core.ts +1 -1
  85. package/src/types.ts +30 -1
  86. package/src/utils/ios/utils.ts +4 -4
  87. package/src/utils/operation-budget.ts +22 -2
  88. package/src/utils/resolve-device.ts +8 -0
  89. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  90. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  91. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  92. package/test/device/manual/interact/readiness.manual.ts +99 -0
  93. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  94. package/test/fixtures/readiness-app/README.md +12 -0
  95. package/test/fixtures/readiness-app/build.ts +37 -0
  96. package/test/fixtures/readiness-app/main.m +27 -0
  97. package/test/unit/observe/android_tree_acquisition.test.ts +6 -0
  98. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  99. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  100. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  101. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  102. package/test/unit/observe/ios-getlogs.test.ts +3 -3
  103. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  104. package/test/unit/observe/readiness.test.ts +39 -0
  105. package/test/unit/observe/readiness_native.test.ts +68 -0
  106. package/test/unit/server/conditional_actions.test.ts +299 -0
  107. package/test/unit/server/conditional_schema.test.ts +59 -0
  108. package/test/unit/server/correlated-evidence.test.ts +194 -0
  109. package/test/unit/server/readiness.test.ts +170 -0
  110. package/test/unit/server/selector_tap_target.test.ts +79 -0
  111. package/test/unit/server/tap_selector.test.ts +2 -0
  112. package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
  113. package/src/observe/android-tree-bridge.ts +0 -178
  114. package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
  115. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
  116. package/test/fixtures/ui-tree-bridge/build.ts +0 -34
  117. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
  118. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
  119. /package/{skills → .agents/skills}/README.md +0 -0
  120. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  121. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  122. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  123. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  124. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  125. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  126. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  127. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -1,7 +1,12 @@
1
+ import { withEvidence, currentEvidenceActionId, validateActionRef, markEvidenceVerification, refreshJourneyEvidence, completeJourneyVerification } from '../observe/evidence.js';
1
2
  import { measure, withTiming } from '../utils/timing.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { runWithBudget } from '../utils/operation-budget.js';
4
5
  import { journeySchema, tapElementControlsSchema } from './journey-schema.js';
6
+ import { readinessSchema, readinessWaitSchema } from './readiness-schema.js';
7
+ import { getAppReadiness, withReadyWait } from '../observe/readiness-service.js';
8
+ import { validateExpectedState } from './condition-schema.js';
9
+ import { conditionFailureCode, createConditionalAction, notifyActionStart, withConditionalAction } from '../interact/conditional-action.js';
5
10
  import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js';
6
11
  import { AndroidManage, iOSManage, ToolsManage } from '../manage/index.js';
7
12
  import { ToolsInteract } from '../interact/index.js';
@@ -43,18 +48,45 @@ function compactAction(result, verificationMode, started = true, stateDelta, tot
43
48
  timing: { total_ms: totalMs }
44
49
  };
45
50
  }
46
- function actionHandler(type, execute) {
51
+ function correlatedHandler(name, handler, action = false, journey = false) {
47
52
  return async (args) => {
53
+ const ref = validateActionRef(args.actionRef);
54
+ const { result, evidence } = await withEvidence({ action, journey, ref }, async () => {
55
+ try {
56
+ return await handler(args);
57
+ }
58
+ catch (error) {
59
+ return wrapToolError(name, error);
60
+ }
61
+ });
62
+ const item = result.content.find(item => item.type === 'text');
63
+ if (item?.text) {
64
+ const payload = JSON.parse(item.text);
65
+ payload.evidence = evidence;
66
+ item.text = JSON.stringify(payload);
67
+ }
68
+ return result;
69
+ };
70
+ }
71
+ function actionHandler(type, execute) {
72
+ return correlatedHandler(type, async (args) => {
48
73
  const timing = {};
49
74
  return withTiming(timing, async () => {
50
75
  const started = performance.now();
51
76
  const controls = interactionControls(args);
77
+ const expected = validateExpectedState(type, args);
78
+ const condition = expected ? createConditionalAction(args.platform, args.deviceId, expected) : undefined;
52
79
  const budgetMs = controls.actionTimeoutMs ?? ({ start_app: 10000, restart_app: 15000, scroll_to_element: 10000 }[type] ?? 5000);
53
80
  let dispatchStarted = false;
54
81
  let result;
55
82
  const dispatchWithBudget = (timeoutMs) => runWithBudget(timeoutMs, async (budget) => {
56
83
  try {
57
- return payloadOf(await execute(args));
84
+ if (!condition)
85
+ return payloadOf(await execute(args));
86
+ return await withConditionalAction(condition, async () => {
87
+ const device = await resolveTargetDevice({ platform: condition.platform, deviceId: condition.deviceId });
88
+ return withResolvedDevice(device, async () => payloadOf(await execute(args)));
89
+ });
58
90
  }
59
91
  finally {
60
92
  dispatchStarted = budget.dispatchStarted;
@@ -68,17 +100,28 @@ function actionHandler(type, execute) {
68
100
  dispatchStarted = true;
69
101
  }
70
102
  catch (error) {
71
- if (controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error)))
103
+ if (!condition && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error)))
72
104
  return wrapToolError(type, error);
73
105
  result = { success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) };
74
106
  }
107
+ const blocked = condition && ['mismatch', 'unavailable'].includes(condition.result.status);
108
+ if (blocked) {
109
+ dispatchStarted = false;
110
+ result = buildActionExecutionResult({
111
+ actionType: type, sourceModule: 'server', device: condition.result.observed?.device,
112
+ selector: result?.target?.selector ?? null, resolved: result?.target?.resolved,
113
+ success: false, uiFingerprintBefore: result?.ui_fingerprint_before ?? null, uiFingerprintAfter: null,
114
+ failure: { failureCode: conditionFailureCode(condition), retryable: false },
115
+ traceSteps: [{ stage: 'verify', timestamp: Date.now(), result: 'failure', attempt_index: 0, metadata: { phase: 'precondition', dispatch_started: false } }]
116
+ });
117
+ }
75
118
  if (result.failure_code === 'TIMEOUT' || /ACTION_TIMEOUT|timeout/i.test(result.error ?? result.reason ?? result.details?.error ?? ''))
76
119
  result.failure_code = 'ACTION_TIMEOUT';
77
120
  if (result.failure_code === 'ACTION_TIMEOUT')
78
121
  result.retryable = !dispatchStarted;
79
122
  if (!result.success && typeof result.retryable !== 'boolean')
80
123
  result.retryable = !dispatchStarted;
81
- result.action_id ??= randomUUID();
124
+ result.action_id ??= currentEvidenceActionId() ?? randomUUID();
82
125
  result.action_type ??= type;
83
126
  recordObservationEvent({
84
127
  step_id: args._stepId,
@@ -92,6 +135,7 @@ function actionHandler(type, execute) {
92
135
  if (result.success && controls.verificationMode === 'light') {
93
136
  const verificationStarted = performance.now();
94
137
  const tree = await withObservationContext({ stepId: args._stepId, purpose: 'verification' }, () => measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () => ToolsObserve.getUITreeHandler({ platform: args.platform ?? result.device?.platform, deviceId: args.deviceId ?? result.device?.id })))).catch(() => null);
138
+ markEvidenceVerification();
95
139
  observationAvailable = !!tree && !tree.error;
96
140
  recordObservationEvent({
97
141
  verification_budget_ms: controls.verificationTimeoutMs,
@@ -102,11 +146,21 @@ function actionHandler(type, execute) {
102
146
  if (observationAvailable && tree.device)
103
147
  delta = ToolsObserve.getLatestStateDelta(tree.device.platform, tree.device.id) ?? undefined;
104
148
  }
149
+ if (!blocked && controls.verificationMode === 'full' && dispatchStarted)
150
+ markEvidenceVerification();
105
151
  const compact = compactAction(result, controls.verificationMode, dispatchStarted, delta, performance.now() - started);
152
+ if (blocked)
153
+ compact.verification.status = 'not_requested';
106
154
  if (result.success && controls.verificationMode === 'light' && !observationAvailable)
107
155
  compact.verification.status = 'unavailable';
108
156
  const evidence = {
109
157
  ...compact,
158
+ ...(condition ? { condition: controls.responseMode === 'compact' && condition.result.status === 'matched'
159
+ ? { ...condition.result, observed: condition.result.observed ? {
160
+ snapshot_session_id: condition.result.observed.snapshot_session_id,
161
+ snapshot_revision: condition.result.observed.snapshot_revision,
162
+ captured_at_ms: condition.result.observed.captured_at_ms
163
+ } : null } : condition.result } : {}),
110
164
  ...(controls.verificationMode === 'light' ? { state_delta_available: !!delta } : {}),
111
165
  ...(result.success && controls.verificationMode === 'light' && !observationAvailable ? { verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true } } : {})
112
166
  };
@@ -115,7 +169,7 @@ function actionHandler(type, execute) {
115
169
  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) }
116
170
  }, controls.responseMode === 'debug');
117
171
  });
118
- };
172
+ }, true);
119
173
  }
120
174
  const handleStartApp = actionHandler('start_app', executeStartApp);
121
175
  const handleRestartApp = actionHandler('restart_app', executeRestartApp);
@@ -131,7 +185,7 @@ async function executeStartApp(args) {
131
185
  const deviceId = getStringArg(args, 'deviceId');
132
186
  const { verificationMode, actionTimeoutMs } = interactionControls(args);
133
187
  const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
134
- ToolsNetwork.notifyActionStart();
188
+ notifyActionStart();
135
189
  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')));
136
190
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
137
191
  const result = buildActionExecutionResult({
@@ -168,7 +222,7 @@ async function executeRestartApp(args) {
168
222
  const deviceId = getStringArg(args, 'deviceId');
169
223
  const { verificationMode, actionTimeoutMs } = interactionControls(args);
170
224
  const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
171
- ToolsNetwork.notifyActionStart();
225
+ notifyActionStart();
172
226
  const bounded = args._journey === true || verificationMode !== 'full' || args.responseMode !== undefined || actionTimeoutMs !== undefined;
173
227
  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)));
174
228
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
@@ -381,9 +435,12 @@ async function handleAdjustControl(args) {
381
435
  platform,
382
436
  deviceId
383
437
  });
438
+ markEvidenceVerification();
384
439
  return wrapResponse(res);
385
440
  }
386
441
  async function handleWaitForUI(args) {
442
+ if (args.requireReady !== undefined && typeof args.requireReady !== 'boolean')
443
+ throw new Error('INVALID_ARGUMENT: requireReady must be boolean');
387
444
  const selector = getObjectArg(args, 'selector');
388
445
  const condition = getStringArg(args, 'condition') ?? 'exists';
389
446
  const timeout_ms = getNumberArg(args, 'timeout_ms') ?? 60000;
@@ -392,10 +449,13 @@ async function handleWaitForUI(args) {
392
449
  const retry = getObjectArg(args, 'retry');
393
450
  const platform = getStringArg(args, 'platform');
394
451
  const deviceId = getStringArg(args, 'deviceId');
395
- const res = await ToolsInteract.waitForUIHandler({ selector, condition, timeout_ms, poll_interval_ms, match, retry, platform, deviceId });
452
+ const execute = (targetPlatform = platform, targetId = deviceId) => ToolsInteract.waitForUIHandler({ selector, condition, timeout_ms, poll_interval_ms, match, retry, platform: targetPlatform, deviceId: targetId });
453
+ const res = args.requireReady === true ? await withReadyWait({ platform, deviceId }, timeout_ms, device => execute(device.platform, device.id), (code, elapsed, samples) => ({ status: 'timeout', error: { code, message: 'Readiness wait could not complete' }, metrics: { latency_ms: elapsed, poll_count: samples, attempts: samples ? 1 : 0 }, requested: { selector, condition, match }, observed: { matched_count: 0, condition_satisfied: false, selected_index: null, last_matched_element: null } })) : await execute();
396
454
  return wrapResponse(res);
397
455
  }
398
456
  async function handleWaitForUIChange(args) {
457
+ if (args.requireReady !== undefined && typeof args.requireReady !== 'boolean')
458
+ throw new Error('INVALID_ARGUMENT: requireReady must be boolean');
399
459
  const platform = getStringArg(args, 'platform');
400
460
  const deviceId = getStringArg(args, 'deviceId');
401
461
  const timeout_ms = getNumberArg(args, 'timeout_ms') ?? 60000;
@@ -403,7 +463,8 @@ async function handleWaitForUIChange(args) {
403
463
  const expected_change = getStringArg(args, 'expected_change');
404
464
  const scope = getStringArg(args, 'scope');
405
465
  const target = getStringArg(args, 'target');
406
- const res = await ToolsInteract.waitForUIChangeHandler({ platform, deviceId, timeout_ms, stability_window_ms, expected_change, scope, target });
466
+ const execute = (targetPlatform = platform, targetId = deviceId) => ToolsInteract.waitForUIChangeHandler({ platform: targetPlatform, deviceId: targetId, timeout_ms, stability_window_ms, expected_change, scope, target });
467
+ const res = args.requireReady === true ? await withReadyWait({ platform, deviceId }, timeout_ms, device => execute(device.platform, device.id), (code, elapsed) => ({ success: false, timeout: true, observed_change: null, elapsed_ms: elapsed, reason: code, error: { code, message: 'Readiness wait could not complete' } })) : await execute();
407
468
  return wrapResponse(res);
408
469
  }
409
470
  async function handleFindElement(args) {
@@ -424,7 +485,7 @@ async function executeTap(args) {
424
485
  const deviceId = getStringArg(args, 'deviceId');
425
486
  const { verificationMode, actionTimeoutMs } = interactionControls(args);
426
487
  const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
427
- ToolsNetwork.notifyActionStart();
488
+ notifyActionStart();
428
489
  const res = await measure('dispatch_ms', () => ToolsInteract.tapHandler({ platform, x, y, deviceId, timeoutMs: actionTimeoutMs }));
429
490
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
430
491
  const result = buildActionExecutionResult({
@@ -450,14 +511,15 @@ async function executeTapElement(args) {
450
511
  if (selector) {
451
512
  const waitFor = getObjectArg(args, 'waitFor');
452
513
  const targetStarted = performance.now();
453
- const resolution = await withObservationContext({ stepId: args._stepId, purpose: 'target' }, () => 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'), deviceId: getStringArg(args, 'deviceId') }))));
514
+ const resolutionTimeoutMs = waitFor?.timeoutMs ?? actionTimeoutMs ?? 5000;
515
+ const resolution = await withObservationContext({ stepId: args._stepId, purpose: 'target' }, () => measure('target_resolution_ms', () => runWithBudget(resolutionTimeoutMs, () => ToolsInteract.waitForUIHandler({ selector, condition: waitFor?.condition ?? 'clickable', timeout_ms: resolutionTimeoutMs, poll_interval_ms: waitFor?.pollIntervalMs ?? 100, match: waitFor?.match, singleObservation: true, singleAttempt: !waitFor, rejectAmbiguous: !waitFor, resolveTapTarget: true, platform: getStringArg(args, 'platform'), deviceId: getStringArg(args, 'deviceId') }))));
454
516
  recordObservationEvent({ step_id: args._stepId, purpose: 'target', target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: resolution.status });
455
517
  if (resolution.status !== 'success' || !resolution.element?.elementId)
456
518
  return wrapResponse({ success: false, action_type: 'tap_element', failure_code: resolution.error?.code ?? 'ELEMENT_NOT_FOUND', retryable: true });
457
519
  resolvedId = resolution.element.elementId;
458
520
  freshTree = resolution._tree;
459
521
  }
460
- ToolsNetwork.notifyActionStart();
522
+ notifyActionStart();
461
523
  if (freshTree)
462
524
  recordObservationConsumer(freshTree, 'target', args._stepId);
463
525
  const res = await measure('dispatch_ms', () => ToolsInteract.tapElementHandler({ elementId: resolvedId, freshTree, verificationMode, timeoutMs: actionTimeoutMs, platform: getStringArg(args, 'platform'), deviceId: getStringArg(args, 'deviceId') }));
@@ -563,7 +625,8 @@ async function handleRunJourney(args) {
563
625
  result.failure_code = 'ASSERTION_TIMEOUT';
564
626
  const success = !!result?.success;
565
627
  if (lightCoveredByAssertion) {
566
- result.verification = { mode: 'light', status: success ? 'unavailable' : 'failed' };
628
+ result.verification = { mode: 'light', status: success ? 'unavailable' :
629
+ (['mismatch', 'unavailable'].includes(result.condition?.status) ? 'not_requested' : 'failed') };
567
630
  if (success) {
568
631
  result.verification_diagnostic = { code: 'VERIFICATION_UNAVAILABLE', retryable: true };
569
632
  pendingLightStep = results.length;
@@ -573,6 +636,8 @@ async function handleRunJourney(args) {
573
636
  const pending = results[pendingLightStep].result;
574
637
  if (typeof result?.observed?.matched_count === 'number') {
575
638
  pending.verification.status = 'observed';
639
+ if (pending.evidence)
640
+ completeJourneyVerification(pending.evidence.action_id);
576
641
  delete pending.verification_diagnostic;
577
642
  const delta = ToolsObserve.getLatestStateDelta(platform, deviceId);
578
643
  if (delta) {
@@ -602,6 +667,7 @@ async function handleRunJourney(args) {
602
667
  if (key !== 'total_ms' && typeof value === 'number')
603
668
  componentTiming[key] = (componentTiming[key] ?? 0) + value;
604
669
  }
670
+ refreshJourneyEvidence(results);
605
671
  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');
606
672
  });
607
673
  }
@@ -615,7 +681,7 @@ async function executeSwipe(args) {
615
681
  const deviceId = getStringArg(args, 'deviceId');
616
682
  const { verificationMode, actionTimeoutMs } = interactionControls(args);
617
683
  const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
618
- ToolsNetwork.notifyActionStart();
684
+ notifyActionStart();
619
685
  const res = await measure('dispatch_ms', () => ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId, timeoutMs: actionTimeoutMs }));
620
686
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
621
687
  const result = buildActionExecutionResult({
@@ -638,7 +704,7 @@ async function executeScrollToElement(args) {
638
704
  const deviceId = getStringArg(args, 'deviceId');
639
705
  const { verificationMode } = interactionControls(args);
640
706
  const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
641
- ToolsNetwork.notifyActionStart();
707
+ notifyActionStart();
642
708
  const res = await measure('dispatch_ms', () => ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId }));
643
709
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null;
644
710
  const result = buildActionExecutionResult({
@@ -666,7 +732,7 @@ async function executeTypeText(args) {
666
732
  const deviceId = getStringArg(args, 'deviceId');
667
733
  const { verificationMode, actionTimeoutMs } = interactionControls(args);
668
734
  const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null;
669
- ToolsNetwork.notifyActionStart();
735
+ notifyActionStart();
670
736
  const res = await measure('dispatch_ms', () => ToolsInteract.typeTextHandler({ text, deviceId, timeoutMs: actionTimeoutMs }));
671
737
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null;
672
738
  const result = buildActionExecutionResult({
@@ -684,7 +750,7 @@ async function executePressBack(args) {
684
750
  const deviceId = getStringArg(args, 'deviceId');
685
751
  const { verificationMode, actionTimeoutMs } = interactionControls(args);
686
752
  const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null;
687
- ToolsNetwork.notifyActionStart();
753
+ notifyActionStart();
688
754
  const res = await measure('dispatch_ms', () => ToolsInteract.pressBackHandler({ deviceId, timeoutMs: actionTimeoutMs }));
689
755
  const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null;
690
756
  const result = buildActionExecutionResult({
@@ -743,6 +809,18 @@ async function handleGetNetworkActivity(args) {
743
809
  return wrapResponse(result);
744
810
  }
745
811
  export const toolHandlers = {
812
+ get_app_readiness: async (args) => {
813
+ const parsed = readinessSchema.safeParse(args);
814
+ if (!parsed.success)
815
+ throw new Error('INVALID_ARGUMENT: invalid readiness arguments');
816
+ return wrapResponse(await getAppReadiness(parsed.data));
817
+ },
818
+ wait_for_app_ready: async (args) => {
819
+ const parsed = readinessWaitSchema.safeParse(args);
820
+ if (!parsed.success)
821
+ throw new Error('INVALID_ARGUMENT: invalid readiness arguments');
822
+ return wrapResponse(await getAppReadiness(parsed.data, true));
823
+ },
746
824
  start_app: handleStartApp,
747
825
  terminate_app: handleTerminateApp,
748
826
  restart_app: handleRestartApp,
@@ -779,6 +857,9 @@ export const toolHandlers = {
779
857
  classify_action_outcome: handleClassifyActionOutcome,
780
858
  get_network_activity: handleGetNetworkActivity
781
859
  };
860
+ for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'read_log_stream', 'run_journey', 'adjust_control']) {
861
+ toolHandlers[name] = correlatedHandler(name, toolHandlers[name], name === 'adjust_control', name === 'run_journey');
862
+ }
782
863
  export async function handleToolCall(name, args = {}) {
783
864
  const handler = toolHandlers[name];
784
865
  if (!handler)
@@ -801,6 +882,7 @@ export async function handleToolCall(name, args = {}) {
801
882
  };
802
883
  return withTiming(measured, async () => {
803
884
  try {
885
+ validateExpectedState(name, args);
804
886
  return addTiming(await handler(args));
805
887
  }
806
888
  catch (error) {
@@ -6,7 +6,7 @@ import { handleToolCall } from './server/tool-handlers.js';
6
6
  export { wrapResponse, toolDefinitions, handleToolCall };
7
7
  export const serverInfo = {
8
8
  name: 'mobile-debug-mcp',
9
- version: '0.32.2'
9
+ version: '0.33.0'
10
10
  };
11
11
  export function createServer() {
12
12
  const server = new Server(serverInfo, {
@@ -75,7 +75,7 @@ export async function isIDBInstalled() {
75
75
  if (!cmd)
76
76
  return false;
77
77
  try {
78
- execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
78
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: remainingBudget(5000), killSignal: 'SIGKILL' });
79
79
  return true;
80
80
  }
81
81
  catch (e) {
@@ -197,7 +197,7 @@ export async function findAppBundle(dir) {
197
197
  export async function getIOSDeviceMetadata(deviceId = "booted") {
198
198
  return new Promise((resolve) => {
199
199
  // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
200
- execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
200
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err, stdout) => {
201
201
  const fallback = {
202
202
  platform: "ios",
203
203
  id: deviceId,
@@ -240,7 +240,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
240
240
  export async function listIOSDevices(appId) {
241
241
  return new Promise((resolve) => {
242
242
  // Query all devices and separately query booted devices to mark them
243
- execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
243
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err, stdout) => {
244
244
  if (err || !stdout)
245
245
  return resolve([]);
246
246
  try {
@@ -249,7 +249,7 @@ export async function listIOSDevices(appId) {
249
249
  const out = [];
250
250
  const checks = [];
251
251
  // Get booted devices set
252
- execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
252
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err2, stdout2) => {
253
253
  const bootedSet = new Set();
254
254
  if (!err2 && stdout2) {
255
255
  try {
@@ -8,13 +8,35 @@ export function runWithBudget(timeoutMs, operation) {
8
8
  }
9
9
  export function remainingBudget(fallback = 0) {
10
10
  const budget = budgets.getStore();
11
- if (!budget || !Number.isFinite(budget.deadline))
11
+ let deadline = budget?.deadline ?? Infinity;
12
+ for (let ancestor = budget?.parent; ancestor; ancestor = ancestor.parent)
13
+ deadline = Math.min(deadline, ancestor.deadline);
14
+ if (!Number.isFinite(deadline))
12
15
  return fallback;
13
- const remaining = Math.floor(budget.deadline - performance.now());
16
+ const remaining = Math.floor(deadline - performance.now());
14
17
  if (remaining <= 0)
15
18
  throw new Error('ACTION_TIMEOUT');
16
19
  return fallback > 0 ? Math.min(fallback, remaining) : remaining;
17
20
  }
21
+ // Pause only this action's budget. Any enclosing operation still bounds the read.
22
+ export async function runWithSeparateBudget(timeoutMs, operation) {
23
+ remainingBudget();
24
+ const action = budgets.getStore();
25
+ const started = performance.now();
26
+ const parent = action?.parent;
27
+ const budget = { deadline: Math.min(parent?.deadline ?? Infinity, started + timeoutMs), dispatchStarted: false, parent };
28
+ try {
29
+ return await budgets.run(budget, async () => {
30
+ const result = await operation();
31
+ remainingBudget();
32
+ return result;
33
+ });
34
+ }
35
+ finally {
36
+ if (action)
37
+ action.deadline += performance.now() - started;
38
+ }
39
+ }
18
40
  export function markDispatchStarted() {
19
41
  remainingBudget();
20
42
  let budget = budgets.getStore();
@@ -1,3 +1,4 @@
1
+ import { bindEvidenceDevice } from '../observe/evidence.js';
1
2
  import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js';
2
3
  import { measure } from './timing.js';
3
4
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -37,9 +38,15 @@ export async function listDevices(platform, appId) {
37
38
  }
38
39
  const deviceContext = new AsyncLocalStorage();
39
40
  export function withResolvedDevice(device, operation) {
41
+ bindEvidenceDevice(device);
40
42
  return deviceContext.run(device, operation);
41
43
  }
42
44
  export async function resolveTargetDevice(opts) {
45
+ const device = await resolveDevice(opts);
46
+ bindEvidenceDevice(device);
47
+ return device;
48
+ }
49
+ async function resolveDevice(opts) {
43
50
  const { platform, appId, prefer, deviceId } = opts;
44
51
  const current = deviceContext.getStore();
45
52
  if (current && current.platform === platform && (!deviceId || current.id === deviceId))
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## Unreleased
6
+ - Made readiness unit-test polling time deterministic while retaining exact sample-count and timeout assertions, eliminating host timer-rounding flakes.
7
+ - Isolated snapshot unit tests from physical device discovery and native handlers so they run without connected devices. Readiness deadline expiry now retains the last valid observation instead of intermittently resetting loading to unknown.
8
+
9
+ ## [0.33.0]
10
+ - Fixed selector taps timing out before one normal Android UI-tree acquisition: a default single lookup now uses the action budget (5 seconds). Selector taps with exists/visible waits resolve actionable containers consistently with clickable waits; hidden/disabled labels remain protected. Acquisition timeouts retain `ACTION_TIMEOUT` instead of reporting a missing element.
11
+ - Added correlated action/observation evidence, explicit `actionRef`, phase/acquisition timestamps, conservative freshness, and bounded metadata history. Journey steps share correlation IDs; snapshot components and stream reads retain their actual device scope. Device log times remain explicitly incomparable with host dispatch times; tracing is unsupported.
12
+ - Added evidence-backed foreground UI readiness observations, bounded readiness waits, and opt-in `requireReady` for existing UI waits. Tree responses include single-sample readiness without extra reads. Unresponsive/debugger-paused signals are explicitly unsupported; iOS probes and metadata reads now respect active operation budgets.
13
+ - Added optional conditional input to `tap`, `tap_element`, `swipe`, Android `type_text`/`press_back`, and journey tap steps. A fresh snapshot check at native dispatch blocks stale or uncheckable expectations and returns condition evidence, including fresh trees in compact failures.
14
+ - Added process-scoped `snapshot_session_id` metadata to UI-tree responses so conditional expectations cannot be reused across server restarts. Successful repeated observations refresh retained revision history timestamps.
15
+
16
+ ## [0.32.4]
17
+ - Removed the optional Android UI-tree bridge runtime and its APK build tooling. Android UI-tree reads use `uiautomator dump` through ADB.
18
+
5
19
  ## [0.32.2]
6
20
  - bug fix
7
21
 
@@ -26,7 +40,7 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
26
40
  ## [0.30.0]
27
41
  - Folded RFC 013 synchronization semantics into the main spec and aligned the interact docs with the shipped `wait_for_ui_change` behavior.
28
42
  - Updated `wait_for_ui_change` to use a 300ms stabilization default and to reset stabilization on new in-place mutations.
29
- - Validated the in-place UI mutation flow on the Modul8 emulator app, including a delayed state-change case.
43
+ - Validated the in-place UI mutation flow on an Android emulator, including a delayed state-change case.
30
44
 
31
45
  ## [0.29.0]
32
46
  - Added empty resource handlers and declared the `resources` capability so Codex MCP discovery can complete the handshake against the published npm package.
@@ -57,7 +71,7 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
57
71
  - RFC-003 wait/synchronization contract with `snapshot_revision`, `captured_at_ms`, and `loading_state`
58
72
  - Added `wait_for_ui_change` for stable in-place UI mutations
59
73
  - Updated `get_ui_tree` and `capture_debug_snapshot` to surface snapshot metadata
60
- - Emulator-validated the new UI-change flow against the Modul8 app
74
+ - Validated the new UI-change flow on an Android emulator
61
75
 
62
76
  ## [0.25.1]
63
77
  - Platform-native element identity metadata for UI targeting
@@ -50,9 +50,10 @@ The report commit SHAs are available locally as `codex/spec-019-baseline` and
50
50
  `codex/spec-019-candidate`; create clean, built checkouts of those refs to
51
51
  reproduce the paired measurement.
52
52
 
53
- For the opt-in Android bridge, run
54
- `LATENCY_DEVICE_ID=emulator-5554 node --import tsx test/device/automated/ui-tree-performance/bridge-probe.ts`.
55
- Its `bridge-probe.json` compares ten bridge and legacy reads, checks a network
53
+ The historical `bridge-probe.json` compares ten bridge and legacy reads, checks a network
56
54
  update, and checks a timer that changes visually without emitting an
57
55
  accessibility event. The timer check catches a stale accessibility cache even
58
56
  when every bridge request calls `getRootInActiveWindow()`.
57
+ The bridge APK source, generator, and build/install probe have been removed;
58
+ this report is retained as historical evidence, not a runnable benchmark in
59
+ the current checkout.
@@ -0,0 +1,62 @@
1
+ # Intent: conditional actions
2
+
3
+ Originator: repository maintainer (user in the current conversation).
4
+ Date: 2026-10-29.
5
+ Status: approved.
6
+
7
+ ## Original problem
8
+
9
+ “Conditional actions: Check expected state immediately before acting; stop and
10
+ return fresh evidence if it changed.”
11
+
12
+ Source: user request in the current conversation, following adoption of the
13
+ repository development process.
14
+
15
+ ## Desired outcome
16
+
17
+ An MCP client can make an action conditional on an expected UI state. The server
18
+ checks that expectation against a fresh observation of the same target device
19
+ immediately before dispatching the action. If the expectation no longer holds,
20
+ the server does not dispatch the action and returns fresh evidence so the client
21
+ can reassess its next step.
22
+
23
+ Affected users and systems: MCP clients/agents, interaction tool dispatch,
24
+ state observation, and Android/iOS adapters for supported actions.
25
+
26
+ ## Observable success criteria
27
+
28
+ - A matching expectation allows the requested action to proceed normally.
29
+ - A mismatching expectation prevents dispatch and clearly reports that the
30
+ action was not performed, together with the newly observed state.
31
+ - If the expected state cannot be checked reliably, the action is not dispatched;
32
+ the response explains the observation failure without inventing evidence.
33
+ - The check and action target the same device.
34
+ - A mismatch does not automatically retry the action or adopt the new state.
35
+ - Existing calls without a condition preserve their behaviour.
36
+
37
+ ## Constraints and exclusions
38
+
39
+ - Reuse existing action, observation, and response conventions where suitable.
40
+ - Define precise supported conditions, actions, evidence, and platform coverage
41
+ in the spec after approval of this intent.
42
+ - A pre-dispatch check cannot guarantee the UI remains unchanged between the
43
+ observation and native input delivery; do not claim atomic device execution.
44
+ - Automatic replanning, rollback, and post-action success verification are
45
+ outside this feature's scope.
46
+ - Preserve unrelated checkout changes.
47
+
48
+ ## Assumptions and open questions for specification
49
+
50
+ - Proposed compatibility approach: conditions are optional on supported actions.
51
+ - Determine the initial action set, including whether journey steps are covered.
52
+ - Determine whether expectations use screen identity, element properties,
53
+ snapshot revisions, or a supported combination.
54
+ - Define fresh mismatch evidence and how it is preserved in compact responses.
55
+ - Existing screen fingerprints deliberately omit some changing values; assess
56
+ their suitability rather than assuming they detect every relevant state change.
57
+
58
+ ## Approval
59
+
60
+ Approved by the originator in the current conversation on 2026-10-29: “yes”,
61
+ in direct response to the complete intent summary and linked artifact. Approval
62
+ covers the captured intent; specification approval remains a separate gate.