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.
- package/.github/workflows/ci.yml +21 -0
- package/README.md +7 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +4 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/index.js +16 -0
- package/dist/observe/evidence.js +9 -2
- package/dist/observe/readiness-service.js +9 -0
- package/dist/observe/readiness.js +15 -8
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +1 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +6 -1
- package/dist/server/tool-handlers.js +203 -96
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +2 -0
- package/dist/utils/resolve-device.js +2 -0
- package/docs/CHANGELOG.md +4 -0
- package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/tools/TOOLS.md +3 -1
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +5 -2
- package/docs/tools/observe.md +12 -5
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/index.ts +16 -15
- package/src/observe/evidence.ts +9 -2
- package/src/observe/readiness-service.ts +5 -0
- package/src/observe/readiness.ts +12 -6
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +1 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +6 -1
- package/src/server/tool-handlers.ts +92 -7
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/utils/exec.ts +2 -0
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +2 -0
- package/src/utils/resolve-device.ts +2 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/fixtures/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -0
- package/test/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -0
- package/test/unit/observe/readiness.test.ts +3 -1
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/worker/DebuggerWorker.java +160 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
615
|
-
result =
|
|
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
|
-
|
|
618
|
-
result =
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
671
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
}
|
package/dist/server-core.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/dist/system/index.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
probeRequired: 'get_debugger_capabilities',
|
|
259
|
+
availability: false,
|
|
260
|
+
selectedProvider: null,
|
|
261
|
+
blockingFailure: deviceFailure ?? adbFailure ?? envFailure
|
|
261
262
|
}
|
|
262
263
|
},
|
|
263
264
|
devices: deviceEntries,
|
package/dist/utils/exec.js
CHANGED
|
@@ -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 = '';
|
package/dist/utils/java.js
CHANGED
|
@@ -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
|
-
|
|
37
|
+
debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio);
|
|
36
38
|
return envStudio;
|
|
37
39
|
}
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME);
|
|
68
70
|
return process.env.JAVA_HOME;
|
|
69
71
|
}
|
|
70
|
-
|
|
72
|
+
debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search');
|
|
71
73
|
}
|
|
72
74
|
catch {
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`);
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
catch (e) {
|
|
130
|
-
|
|
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
|
|