mobile-debug-mcp 0.33.0 → 0.34.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 (87) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/README.md +7 -0
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/README.md +4 -0
  8. package/agent-plugin/mcp.json +1 -1
  9. package/agent-plugin/plugin.json +1 -1
  10. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
  11. package/dist/debugger/android.js +78 -0
  12. package/dist/debugger/guard.js +30 -0
  13. package/dist/debugger/sessions.js +262 -0
  14. package/dist/debugger/worker.js +165 -0
  15. package/dist/interact/index.js +16 -0
  16. package/dist/observe/evidence.js +9 -2
  17. package/dist/observe/readiness-service.js +9 -0
  18. package/dist/observe/readiness.js +15 -8
  19. package/dist/observe/system-trace-android.js +135 -0
  20. package/dist/observe/system-trace.js +285 -0
  21. package/dist/server/debugger-handlers.js +38 -0
  22. package/dist/server/debugger-schema.js +42 -0
  23. package/dist/server/journey-schema.js +1 -0
  24. package/dist/server/system-trace-schema.js +17 -0
  25. package/dist/server/tool-definitions.js +6 -1
  26. package/dist/server/tool-handlers.js +203 -96
  27. package/dist/server-core.js +3 -3
  28. package/dist/server.js +34 -0
  29. package/dist/system/index.js +5 -4
  30. package/dist/utils/exec.js +2 -0
  31. package/dist/utils/java.js +14 -12
  32. package/dist/utils/operation-budget.js +2 -0
  33. package/dist/utils/resolve-device.js +2 -0
  34. package/docs/CHANGELOG.md +4 -0
  35. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  36. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  37. package/docs/specs/023-android-system-traces/intent.md +40 -0
  38. package/docs/specs/023-android-system-traces/plan.md +117 -0
  39. package/docs/specs/023-android-system-traces/spec.md +120 -0
  40. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  41. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  42. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  43. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  44. package/docs/tools/TOOLS.md +3 -1
  45. package/docs/tools/debugger.md +104 -0
  46. package/docs/tools/evidence.md +5 -2
  47. package/docs/tools/observe.md +12 -5
  48. package/docs/tools/system-traces.md +126 -0
  49. package/package.json +1 -1
  50. package/src/debugger/android.ts +65 -0
  51. package/src/debugger/guard.ts +26 -0
  52. package/src/debugger/sessions.ts +168 -0
  53. package/src/debugger/worker.ts +122 -0
  54. package/src/interact/index.ts +16 -15
  55. package/src/observe/evidence.ts +9 -2
  56. package/src/observe/readiness-service.ts +5 -0
  57. package/src/observe/readiness.ts +12 -6
  58. package/src/observe/system-trace-android.ts +133 -0
  59. package/src/observe/system-trace.ts +218 -0
  60. package/src/server/debugger-handlers.ts +29 -0
  61. package/src/server/debugger-schema.ts +37 -0
  62. package/src/server/journey-schema.ts +1 -0
  63. package/src/server/system-trace-schema.ts +17 -0
  64. package/src/server/tool-definitions.ts +6 -1
  65. package/src/server/tool-handlers.ts +92 -7
  66. package/src/server-core.ts +3 -3
  67. package/src/server.ts +24 -0
  68. package/src/system/index.ts +7 -5
  69. package/src/utils/exec.ts +2 -0
  70. package/src/utils/java.ts +13 -12
  71. package/src/utils/operation-budget.ts +2 -0
  72. package/src/utils/resolve-device.ts +2 -0
  73. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  74. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  75. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  76. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  77. package/test/fixtures/debugger-app/README.md +13 -0
  78. package/test/fixtures/debugger-app/build.ts +29 -0
  79. package/test/helpers/perfetto.ts +59 -0
  80. package/test/unit/debugger/guards.test.ts +46 -0
  81. package/test/unit/debugger/native.test.ts +33 -0
  82. package/test/unit/debugger/sessions.test.ts +83 -0
  83. package/test/unit/observe/readiness.test.ts +3 -1
  84. package/test/unit/observe/system-trace-android.test.ts +52 -0
  85. package/test/unit/observe/system-trace.test.ts +166 -0
  86. package/test/unit/server/system-trace.test.ts +64 -0
  87. package/worker/DebuggerWorker.java +160 -0
@@ -1,4 +1,10 @@
1
- import { withEvidence, currentEvidenceActionId, validateActionRef, markEvidenceVerification, refreshJourneyEvidence, completeJourneyVerification } from '../observe/evidence.js';
1
+ import { handleDebugger } from './debugger-handlers.js';
2
+ import { debuggerContracts } from './debugger-schema.js';
3
+ import { debuggerSessions } from '../debugger/sessions.js';
4
+ import { withDebuggerGuard, guardDispatched } from '../debugger/guard.js';
5
+ import { systemTraces, TraceError } from '../observe/system-trace.js';
6
+ import { systemTraceTools, traceTargetSchema, traceStartSchema, traceControlSchema } from './system-trace-schema.js';
7
+ import { currentEvidenceCorrelationId, bindSystemTrace, withEvidence, currentEvidenceActionId, validateActionRef, markEvidenceVerification, refreshJourneyEvidence, completeJourneyVerification } from '../observe/evidence.js';
2
8
  import { measure, withTiming } from '../utils/timing.js';
3
9
  import { randomUUID } from 'node:crypto';
4
10
  import { runWithBudget } from '../utils/operation-budget.js';
@@ -16,6 +22,7 @@ import { ToolsNetwork } from '../network/index.js';
16
22
  import { getSystemStatus } from '../system/index.js';
17
23
  import { recordObservationConsumer, recordObservationEvent, withObservationContext } from '../observe/performance-metrics.js';
18
24
  import { buildActionExecutionResult, captureActionFingerprint, getArrayArg, getBooleanArg, getNumberArg, getObjectArg, getStringArg, inferGenericFailure, inferScrollFailure, requireBooleanArg, requireNumberArg, requireObjectArg, requireStringArg, wrapResponse, wrapToolError } from './common.js';
25
+ const systemTraceToolNames = new Set(systemTraceTools.map(tool => tool.name));
19
26
  function interactionControls(args) {
20
27
  const verificationMode = getStringArg(args, 'verificationMode') ?? 'full';
21
28
  const responseMode = getStringArg(args, 'responseMode') ?? 'debug';
@@ -56,7 +63,7 @@ function correlatedHandler(name, handler, action = false, journey = false) {
56
63
  return await handler(args);
57
64
  }
58
65
  catch (error) {
59
- return wrapToolError(name, error);
66
+ return error instanceof TraceError ? { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true } : wrapToolError(name, error);
60
67
  }
61
68
  });
62
69
  const item = result.content.find(item => item.type === 'text');
@@ -570,106 +577,125 @@ async function handleRunJourney(args) {
570
577
  const steps = parsed.data.steps;
571
578
  const device = await resolveTargetDevice({ platform, deviceId: parsed.data.deviceId });
572
579
  const deviceId = device.id;
573
- return withResolvedDevice(device, async () => {
574
- const started = performance.now();
575
- const results = [];
576
- let stoppedAt = null;
577
- let pendingLightStep = null;
578
- for (let index = 0; index < steps.length; index++) {
579
- const step = steps[index];
580
- const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full';
581
- const next = steps[index + 1];
582
- const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
583
- next.assertion?.kind === 'element_visible';
584
- const stepStart = performance.now();
585
- let result;
586
- if (stoppedAt) {
587
- results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } });
588
- continue;
589
- }
590
- try {
591
- if (step.type === 'tap') {
592
- result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }));
580
+ const finishTrace = parsed.data.systemTraceId
581
+ ? await systemTraces.link(parsed.data.systemTraceId, device, currentEvidenceCorrelationId()) : undefined;
582
+ if (parsed.data.systemTraceId)
583
+ bindSystemTrace(parsed.data.systemTraceId);
584
+ try {
585
+ return await withResolvedDevice(device, async () => {
586
+ const started = performance.now();
587
+ const results = [];
588
+ let stoppedAt = null;
589
+ let pendingLightStep = null;
590
+ for (let index = 0; index < steps.length; index++) {
591
+ const step = steps[index];
592
+ if (platform === 'android' && debuggerSessions.paused(deviceId)) {
593
+ results.push({ id: step.id, type: step.type, status: 'failed', success: false, result: { success: false, failure_code: 'DEBUGGER_PAUSED', dispatch_started: false, delivery_status: 'not_delivered' } });
594
+ stoppedAt = step.id;
595
+ break;
593
596
  }
594
- else if (step.type === 'wait') {
595
- result = await withObservationContext({ stepId: step.id, purpose: 'target' }, () => ToolsInteract.waitForUIHandler({ selector: step.selector, condition: step.condition ?? 'exists', timeout_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, platform, deviceId }));
596
- result = { success: result.status === 'success', ...result };
597
+ const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full';
598
+ const next = steps[index + 1];
599
+ const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
600
+ next.assertion?.kind === 'element_visible';
601
+ const stepStart = performance.now();
602
+ let result;
603
+ if (stoppedAt) {
604
+ results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } });
605
+ continue;
597
606
  }
598
- else if (step.type === 'assert') {
599
- result = await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget(step.timeoutMs ?? 5000, async () => {
600
- const assertion = step.assertion;
601
- if (assertion?.kind === 'element_visible')
602
- result = await ToolsInteract.expectElementVisibleHandler({ selector: assertion.selector, timeout_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, platform, deviceId });
603
- else if (assertion?.kind === 'element_absent')
604
- result = await expectElementAbsent({ selector: assertion.selector, timeoutMs: step.timeoutMs ?? 5000, pollIntervalMs: step.pollIntervalMs ?? 100, platform, deviceId });
605
- else if (assertion?.kind === 'screen_fingerprint')
606
- result = await expectFingerprint({ fingerprint: assertion.fingerprint, timeoutMs: step.timeoutMs ?? 5000, platform, deviceId });
607
- else if (assertion?.kind === 'state_equals')
608
- result = await ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, exact_budget: true });
609
- else
610
- result = { success: false, failure_code: 'INVALID_ASSERTION' };
611
- return result;
612
- }));
607
+ try {
608
+ if (step.type === 'tap') {
609
+ result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }));
610
+ }
611
+ else if (step.type === 'wait') {
612
+ result = await withObservationContext({ stepId: step.id, purpose: 'target' }, () => ToolsInteract.waitForUIHandler({ selector: step.selector, condition: step.condition ?? 'exists', timeout_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, platform, deviceId }));
613
+ result = { success: result.status === 'success', ...result };
614
+ }
615
+ else if (step.type === 'assert') {
616
+ result = await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget(step.timeoutMs ?? 5000, async () => {
617
+ const assertion = step.assertion;
618
+ if (assertion?.kind === 'element_visible')
619
+ result = await ToolsInteract.expectElementVisibleHandler({ selector: assertion.selector, timeout_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, platform, deviceId });
620
+ else if (assertion?.kind === 'element_absent')
621
+ result = await expectElementAbsent({ selector: assertion.selector, timeoutMs: step.timeoutMs ?? 5000, pollIntervalMs: step.pollIntervalMs ?? 100, platform, deviceId });
622
+ else if (assertion?.kind === 'screen_fingerprint')
623
+ result = await expectFingerprint({ fingerprint: assertion.fingerprint, timeoutMs: step.timeoutMs ?? 5000, platform, deviceId });
624
+ else if (assertion?.kind === 'state_equals')
625
+ result = await ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, exact_budget: true });
626
+ else
627
+ result = { success: false, failure_code: 'INVALID_ASSERTION' };
628
+ return result;
629
+ }));
630
+ }
631
+ else if (step.type === 'start_app') {
632
+ result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }));
633
+ }
634
+ else {
635
+ result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' };
636
+ }
613
637
  }
614
- else if (step.type === 'start_app') {
615
- result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }));
638
+ catch (error) {
639
+ result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) };
616
640
  }
617
- else {
618
- result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' };
641
+ if (platform === 'android' && debuggerSessions.paused(deviceId)) {
642
+ result.success = false;
643
+ result.failure_code = 'DEBUGGER_PAUSED';
644
+ result.retryable = false;
619
645
  }
620
- }
621
- catch (error) {
622
- result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) };
623
- }
624
- if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? ''))
625
- result.failure_code = 'ASSERTION_TIMEOUT';
626
- const success = !!result?.success;
627
- if (lightCoveredByAssertion) {
628
- result.verification = { mode: 'light', status: success ? 'unavailable' :
629
- (['mismatch', 'unavailable'].includes(result.condition?.status) ? 'not_requested' : 'failed') };
630
- if (success) {
631
- result.verification_diagnostic = { code: 'VERIFICATION_UNAVAILABLE', retryable: true };
632
- pendingLightStep = results.length;
646
+ if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? ''))
647
+ result.failure_code = 'ASSERTION_TIMEOUT';
648
+ const success = !!result?.success;
649
+ if (lightCoveredByAssertion) {
650
+ result.verification = { mode: 'light', status: success ? 'unavailable' :
651
+ (['mismatch', 'unavailable'].includes(result.condition?.status) ? 'not_requested' : 'failed') };
652
+ if (success) {
653
+ result.verification_diagnostic = { code: 'VERIFICATION_UNAVAILABLE', retryable: true };
654
+ pendingLightStep = results.length;
655
+ }
633
656
  }
634
- }
635
- if (step.type === 'assert' && pendingLightStep !== null) {
636
- const pending = results[pendingLightStep].result;
637
- if (typeof result?.observed?.matched_count === 'number') {
638
- pending.verification.status = 'observed';
639
- if (pending.evidence)
640
- completeJourneyVerification(pending.evidence.action_id);
641
- delete pending.verification_diagnostic;
642
- const delta = ToolsObserve.getLatestStateDelta(platform, deviceId);
643
- if (delta) {
644
- pending.state_delta = delta;
645
- pending.state_delta_available = true;
657
+ if (step.type === 'assert' && pendingLightStep !== null) {
658
+ const pending = results[pendingLightStep].result;
659
+ if (typeof result?.observed?.matched_count === 'number') {
660
+ pending.verification.status = 'observed';
661
+ if (pending.evidence)
662
+ completeJourneyVerification(pending.evidence.action_id);
663
+ delete pending.verification_diagnostic;
664
+ const delta = ToolsObserve.getLatestStateDelta(platform, deviceId);
665
+ if (delta) {
666
+ pending.state_delta = delta;
667
+ pending.state_delta_available = true;
668
+ }
646
669
  }
670
+ pendingLightStep = null;
647
671
  }
648
- pendingLightStep = null;
649
- }
650
- if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type))
651
- result = {
652
- success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
653
- ...(result.error?.code ? { failure_code: result.error.code } : {}),
654
- ...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
655
- ...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
656
- ...(result.actual_state ? { observed: result.actual_state } : {})
657
- };
658
- results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - stepStart } });
659
- if (!success)
660
- stoppedAt = step.id;
661
- }
662
- const failureSnapshot = stoppedAt && captureOnFailure && responseMode === 'debug'
663
- ? await ToolsObserve.captureDebugSnapshotHandler({ platform, deviceId, includeScreenshot: false }).catch(() => null) : undefined;
664
- const componentTiming = {};
665
- for (const step of results)
666
- for (const [key, value] of Object.entries(step.result?.timing ?? {})) {
667
- if (key !== 'total_ms' && typeof value === 'number')
668
- componentTiming[key] = (componentTiming[key] ?? 0) + value;
672
+ if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type))
673
+ result = {
674
+ success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
675
+ ...(result.error?.code ? { failure_code: result.error.code } : {}),
676
+ ...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
677
+ ...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
678
+ ...(result.actual_state ? { observed: result.actual_state } : {})
679
+ };
680
+ results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - stepStart } });
681
+ if (!success)
682
+ stoppedAt = step.id;
669
683
  }
670
- refreshJourneyEvidence(results);
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');
672
- });
684
+ const failureSnapshot = stoppedAt && captureOnFailure && responseMode === 'debug'
685
+ ? await ToolsObserve.captureDebugSnapshotHandler({ platform, deviceId, includeScreenshot: false }).catch(() => null) : undefined;
686
+ const componentTiming = {};
687
+ for (const step of results)
688
+ for (const [key, value] of Object.entries(step.result?.timing ?? {})) {
689
+ if (key !== 'total_ms' && typeof value === 'number')
690
+ componentTiming[key] = (componentTiming[key] ?? 0) + value;
691
+ }
692
+ refreshJourneyEvidence(results);
693
+ return wrapResponse({ ...(finishTrace ? { system_trace: await finishTrace() } : {}), 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');
694
+ });
695
+ }
696
+ finally {
697
+ await finishTrace?.();
698
+ }
673
699
  }
674
700
  async function executeSwipe(args) {
675
701
  const platform = getStringArg(args, 'platform') ?? 'android';
@@ -808,7 +834,38 @@ async function handleGetNetworkActivity(args) {
808
834
  const result = await ToolsNetwork.getNetworkActivity({ platform, deviceId });
809
835
  return wrapResponse(result);
810
836
  }
837
+ async function handleSystemTrace(name, args) {
838
+ const schema = name === 'start_system_trace' ? traceStartSchema : name === 'get_system_trace_capabilities' ? traceTargetSchema : traceControlSchema;
839
+ const parsed = schema.safeParse(args);
840
+ if (!parsed.success)
841
+ throw new TraceError('INVALID_ARGUMENT');
842
+ if (name === 'get_system_trace_capabilities' || name === 'start_system_trace') {
843
+ const target = parsed.data;
844
+ if (target.platform !== 'android') {
845
+ if (name === 'start_system_trace')
846
+ throw new TraceError('UNSUPPORTED_PLATFORM');
847
+ return wrapResponse({ supported: false, backend: 'perfetto', device: null, prerequisites: {}, reasons: ['UNSUPPORTED_PLATFORM'], enabled_sources: [], unavailable_categories: [], limitations: ['Android only'] });
848
+ }
849
+ let device;
850
+ try {
851
+ device = await resolveTargetDevice(target);
852
+ }
853
+ catch {
854
+ if (name === 'start_system_trace')
855
+ throw new TraceError('TRACE_UNSUPPORTED');
856
+ return wrapResponse({ supported: false, backend: 'perfetto', device: null, prerequisites: { device: false }, reasons: ['DEVICE_UNAVAILABLE'], enabled_sources: [], unavailable_categories: [], limitations: ['An authorised Android device is required'] });
857
+ }
858
+ if (name === 'get_system_trace_capabilities')
859
+ return wrapResponse(await systemTraces.capabilities(device.id));
860
+ const result = await systemTraces.start(device.id, target.durationMs);
861
+ return { ...wrapResponse(result), ...(result.state === 'failed' ? { isError: true } : {}) };
862
+ }
863
+ const operation = name === 'stop_system_trace' ? 'stop' : name === 'cancel_system_trace' ? 'cancel' : 'status';
864
+ const result = await systemTraces.control(parsed.data.traceId, operation);
865
+ return { ...wrapResponse(result), ...(['failed', 'cancelled'].includes(result.state) ? { isError: true } : {}) };
866
+ }
811
867
  export const toolHandlers = {
868
+ ...Object.fromEntries(systemTraceTools.map(({ name }) => [name, (args) => handleSystemTrace(name, args)])),
812
869
  get_app_readiness: async (args) => {
813
870
  const parsed = readinessSchema.safeParse(args);
814
871
  if (!parsed.success)
@@ -860,7 +917,9 @@ export const toolHandlers = {
860
917
  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
918
  toolHandlers[name] = correlatedHandler(name, toolHandlers[name], name === 'adjust_control', name === 'run_journey');
862
919
  }
863
- export async function handleToolCall(name, args = {}) {
920
+ export async function handleToolCall(name, args = {}, signal) {
921
+ if (Object.hasOwn(debuggerContracts, name))
922
+ return handleDebugger(name, args, signal);
864
923
  const handler = toolHandlers[name];
865
924
  if (!handler)
866
925
  throw new Error(`Unknown tool: ${name}`);
@@ -882,10 +941,58 @@ export async function handleToolCall(name, args = {}) {
882
941
  };
883
942
  return withTiming(measured, async () => {
884
943
  try {
885
- validateExpectedState(name, args);
886
- return addTiming(await handler(args));
944
+ if (!systemTraceToolNames.has(name))
945
+ validateExpectedState(name, args);
946
+ const guarded = ['start_app', 'terminate_app', 'restart_app', 'reset_app_data', 'install_app', 'build_and_install', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'adjust_control', 'run_journey', 'wait_for_ui', 'wait_for_ui_change', 'wait_for_screen_change', 'expect_screen', 'expect_element_visible', 'expect_state'].includes(name);
947
+ if (guarded && args.platform !== 'ios' && debuggerSessions.hasActiveSessions()) {
948
+ const device = await resolveTargetDevice({ platform: 'android', deviceId: args.deviceId });
949
+ return withResolvedDevice(device, () => withDebuggerGuard('android', device.id, async () => {
950
+ const before = debuggerSessions.paused(device.id);
951
+ if (before)
952
+ return wrapResponse({ success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before });
953
+ let response;
954
+ try {
955
+ response = await handler(args);
956
+ }
957
+ catch (error) {
958
+ if (!debuggerSessions.paused(device.id))
959
+ throw error;
960
+ response = wrapResponse({ success: false });
961
+ }
962
+ const pause = debuggerSessions.paused(device.id);
963
+ if (pause) {
964
+ const item = response.content.find(item => item.type === 'text');
965
+ if (item?.text) {
966
+ const payload = JSON.parse(item.text);
967
+ Object.assign(payload, { success: false, failure_code: 'DEBUGGER_PAUSED', retryable: false,
968
+ dispatch_started: guardDispatched(), delivery_status: payload.success ? 'delivered' : guardDispatched() ? 'unknown' : 'not_delivered',
969
+ verification: { status: 'blocked', reason: 'DEBUGGER_PAUSED' }, debugger: pause });
970
+ item.text = JSON.stringify(payload);
971
+ }
972
+ }
973
+ return addTiming(response);
974
+ }));
975
+ }
976
+ const response = await handler(args);
977
+ if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name) && args.platform !== 'ios') {
978
+ const item = response.content.find(item => item.type === 'text');
979
+ if (item?.text) {
980
+ try {
981
+ const payload = JSON.parse(item.text);
982
+ const device = payload.device ?? payload.evidence?.device;
983
+ const id = device?.id ?? args.deviceId;
984
+ const pause = id && device?.platform !== 'ios' ? debuggerSessions.paused(String(id)) : null;
985
+ if (pause)
986
+ item.text = JSON.stringify({ ...payload, debugger: pause });
987
+ }
988
+ catch { /* Non-JSON observation */ }
989
+ }
990
+ }
991
+ return addTiming(response);
887
992
  }
888
993
  catch (error) {
994
+ if (error instanceof TraceError)
995
+ return { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true };
889
996
  console.error(`Error executing tool ${name}:`, error);
890
997
  return addTiming(wrapToolError(name, error));
891
998
  }
@@ -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.33.0'
9
+ version: '0.34.0'
10
10
  };
11
11
  export function createServer() {
12
12
  const server = new Server(serverInfo, {
@@ -27,9 +27,9 @@ export function createServer() {
27
27
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
28
28
  tools: toolDefinitions
29
29
  }));
30
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
30
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
31
31
  const { name, arguments: args } = request.params;
32
- return handleToolCall(name, args);
32
+ return handleToolCall(name, args, extra.signal);
33
33
  });
34
34
  return server;
35
35
  }
package/dist/server.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { debuggerSessions } from './debugger/sessions.js';
3
+ import { systemTraces } from './observe/system-trace.js';
2
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
5
  import { createServer } from './server-core.js';
4
6
  import { getSystemStatus } from './system/index.js';
@@ -9,6 +11,38 @@ if (process.env.MOBILE_DEBUG_MCP_STARTUP_HEALTHCHECK === '1') {
9
11
  }).catch((e) => console.warn('[startup] healthcheck failed:', e instanceof Error ? e.message : String(e)));
10
12
  }
11
13
  const transport = new StdioServerTransport();
14
+ let shuttingDown = false;
15
+ async function shutdown() {
16
+ if (shuttingDown)
17
+ return;
18
+ shuttingDown = true;
19
+ const deadline = setTimeout(() => { console.error('System trace shutdown cleanup timed out; owned device captures remain duration-bounded.'); process.exit(1); }, 10000);
20
+ let exitCode = 0;
21
+ try {
22
+ const [remaining, debuggers] = await Promise.all([systemTraces.shutdown(), debuggerSessions.shutdown()]);
23
+ if (debuggers.length) {
24
+ exitCode = 1;
25
+ console.error('Debugger shutdown cleanup incomplete', debuggers.map(s => s.cleanup));
26
+ }
27
+ if (remaining.length) {
28
+ exitCode = 1;
29
+ console.error('System trace shutdown cleanup incomplete:', remaining.map(s => ({ trace_id: s.trace_id, device: s.device, cleanup: s.cleanup })));
30
+ }
31
+ await server.close();
32
+ }
33
+ catch {
34
+ exitCode = 1;
35
+ console.error('Server shutdown cleanup failed.');
36
+ }
37
+ finally {
38
+ clearTimeout(deadline);
39
+ process.exit(exitCode);
40
+ }
41
+ }
42
+ server.onclose = () => { void shutdown(); };
43
+ process.stdin.on('end', () => { void shutdown(); });
44
+ process.on('SIGINT', () => { void shutdown(); });
45
+ process.on('SIGTERM', () => { void shutdown(); });
12
46
  async function main() {
13
47
  await server.connect(transport);
14
48
  }
@@ -221,7 +221,7 @@ function buildAndroidPlatform(android, hostSupported) {
221
221
  },
222
222
  providers: {
223
223
  adb: { available: adbAvailable, selected: true, tool: 'adb' },
224
- jdwp: { available: adbAvailable && android.devices > 0, selected: false, tool: 'adb' }
224
+ jdwp: { available: false, selected: false, tool: 'adb', probeRequired: 'get_debugger_capabilities' }
225
225
  },
226
226
  capabilities: {
227
227
  device_discovery: {
@@ -255,9 +255,10 @@ function buildAndroidPlatform(android, hostSupported) {
255
255
  blockingFailure: logAvailable ? null : adbFailure ?? envFailure
256
256
  },
257
257
  debug: {
258
- availability: adbAvailable && android.devices > 0,
259
- selectedProvider: adbAvailable && android.devices > 0 ? 'JDWP' : null,
260
- blockingFailure: adbAvailable && android.devices > 0 ? null : deviceFailure ?? adbFailure ?? envFailure
258
+ probeRequired: 'get_debugger_capabilities',
259
+ availability: false,
260
+ selectedProvider: null,
261
+ blockingFailure: deviceFailure ?? adbFailure ?? envFailure
261
262
  }
262
263
  },
263
264
  devices: deviceEntries,
@@ -1,9 +1,11 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js';
1
2
  import { remainingBudget } from './operation-budget.js';
2
3
  import { spawn } from 'child_process';
3
4
  export async function execCmd(cmd, args, opts = {}) {
4
5
  const { env, cwd, shell } = opts;
5
6
  const timeout = remainingBudget(opts.timeout ?? 0);
6
7
  return new Promise((resolve, reject) => {
8
+ checkDebuggerGuard();
7
9
  const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell });
8
10
  let stdout = '';
9
11
  let stderr = '';
@@ -25,17 +25,19 @@ function javaVersionOf(javaBin) {
25
25
  return undefined;
26
26
  }
27
27
  }
28
- export async function detectJavaHome() {
28
+ export async function detectJavaHome(options = {}) {
29
+ const debug = (...args) => { if (!options.quiet)
30
+ console.debug(...args); };
29
31
  try {
30
32
  // 1) Honor explicit ANDROID_STUDIO_JDK env (highest priority)
31
33
  const envStudio = process.env.ANDROID_STUDIO_JDK || process.env.ANDROID_STUDIO_JBR;
32
34
  if (envStudio && existsSync(path.join(envStudio, 'bin', 'java'))) {
33
35
  const v = javaVersionOf(path.join(envStudio, 'bin', 'java'));
34
36
  if (isJavaVersionAcceptable(v)) {
35
- console.debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio);
37
+ debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio);
36
38
  return envStudio;
37
39
  }
38
- console.debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions');
40
+ debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions');
39
41
  }
40
42
  // 2) Android Studio JBR candidates (prefer these over JAVA_HOME)
41
43
  const jbrCandidates = [
@@ -53,7 +55,7 @@ export async function detectJavaHome() {
53
55
  if (existsSync(javaBin)) {
54
56
  const v = javaVersionOf(javaBin);
55
57
  if (isJavaVersionAcceptable(v)) {
56
- console.debug('[java.detect] Found Android Studio JBR at:', p);
58
+ debug('[java.detect] Found Android Studio JBR at:', p);
57
59
  return p;
58
60
  }
59
61
  }
@@ -64,13 +66,13 @@ export async function detectJavaHome() {
64
66
  const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java');
65
67
  const v = javaVersionOf(javaBin);
66
68
  if (isJavaVersionAcceptable(v)) {
67
- console.debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME);
69
+ debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME);
68
70
  return process.env.JAVA_HOME;
69
71
  }
70
- console.debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search');
72
+ debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search');
71
73
  }
72
74
  catch {
73
- console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK');
75
+ debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK');
74
76
  }
75
77
  }
76
78
  // 4) macOS explicit path for JDK 17
@@ -84,7 +86,7 @@ export async function detectJavaHome() {
84
86
  return out17;
85
87
  }
86
88
  catch (e) {
87
- console.debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e));
89
+ debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e));
88
90
  }
89
91
  try {
90
92
  const out21 = execSync('/usr/libexec/java_home -v 21', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
@@ -92,7 +94,7 @@ export async function detectJavaHome() {
92
94
  return out21;
93
95
  }
94
96
  catch (e) {
95
- console.debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e));
97
+ debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e));
96
98
  }
97
99
  // 6) macOS common JDK locations
98
100
  try {
@@ -105,7 +107,7 @@ export async function detectJavaHome() {
105
107
  }
106
108
  }
107
109
  catch (e) {
108
- console.debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e));
110
+ debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e));
109
111
  }
110
112
  // 7) Linux locations
111
113
  const linuxCandidates = [
@@ -122,12 +124,12 @@ export async function detectJavaHome() {
122
124
  return p;
123
125
  }
124
126
  catch (e) {
125
- console.debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`);
127
+ debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`);
126
128
  }
127
129
  }
128
130
  }
129
131
  catch (e) {
130
- console.debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e));
132
+ debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e));
131
133
  }
132
134
  return undefined;
133
135
  }
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js';
1
2
  import { AsyncLocalStorage } from 'node:async_hooks';
2
3
  const budgets = new AsyncLocalStorage();
3
4
  export function hasFiniteBudget() { return Number.isFinite(budgets.getStore()?.deadline); }
@@ -38,6 +39,7 @@ export async function runWithSeparateBudget(timeoutMs, operation) {
38
39
  }
39
40
  }
40
41
  export function markDispatchStarted() {
42
+ checkDebuggerGuard(true);
41
43
  remainingBudget();
42
44
  let budget = budgets.getStore();
43
45
  while (budget) {
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js';
1
2
  import { bindEvidenceDevice } from '../observe/evidence.js';
2
3
  import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js';
3
4
  import { measure } from './timing.js';
@@ -42,6 +43,7 @@ export function withResolvedDevice(device, operation) {
42
43
  return deviceContext.run(device, operation);
43
44
  }
44
45
  export async function resolveTargetDevice(opts) {
46
+ checkDebuggerGuard();
45
47
  const device = await resolveDevice(opts);
46
48
  bindEvidenceDevice(device);
47
49
  return device;
package/docs/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
5
  ## Unreleased
6
+
7
+ ## [0.34.0]
8
+ - Added optional Android JDI runtime debugging: explicit device/process attach, source breakpoints/events, bounded stacks and shallow locals, stepping/resume, stale-reference rejection, pause-aware UI guards/readiness, expiry and owned detach/cleanup. Requires a host JDK 17+ and a running debuggable app; evaluation, mutation, native/coroutine and iOS debugging remain unsupported.
9
+ - Added optional bounded Android Perfetto capture with capability/start/status/stop/cancel tools, explicit journey trace linkage, local artifacts, process-ownership checks and retryable cleanup. iOS tracing and automatic trace analysis remain unsupported.
6
10
  - Made readiness unit-test polling time deterministic while retaining exact sample-count and timeout assertions, eliminating host timer-rounding flakes.
7
11
  - 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
12