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'
@@ -41,6 +47,8 @@ import {
41
47
  wrapToolError
42
48
  } from './common.js'
43
49
 
50
+ const systemTraceToolNames = new Set(systemTraceTools.map(tool => tool.name))
51
+
44
52
  type PlatformArg = 'android' | 'ios'
45
53
  type ProjectTypeArg = 'native' | 'kmp' | 'react-native' | 'flutter'
46
54
  type ExpectElementSelectorArg = { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }
@@ -86,7 +94,7 @@ function correlatedHandler(name: string, handler: ToolHandler, action = false, j
86
94
  const ref = validateActionRef(args.actionRef)
87
95
  const { result, evidence } = await withEvidence({ action, journey, ref }, async () => {
88
96
  try { return await handler(args) }
89
- catch (error) { return wrapToolError(name, error) }
97
+ catch (error) { return error instanceof TraceError ? { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true } : wrapToolError(name, error) }
90
98
  })
91
99
  const item = result.content.find(item => item.type === 'text')
92
100
  if (item?.text) {
@@ -612,13 +620,22 @@ async function handleRunJourney(args: ToolCallArgs) {
612
620
  const steps = parsed.data.steps as Array<Record<string, any>>
613
621
  const device = await resolveTargetDevice({ platform, deviceId: parsed.data.deviceId })
614
622
  const deviceId = device.id
615
- return withResolvedDevice(device, async () => {
623
+ const finishTrace = parsed.data.systemTraceId
624
+ ? await systemTraces.link(parsed.data.systemTraceId, device, currentEvidenceCorrelationId()!) : undefined
625
+ if (parsed.data.systemTraceId) bindSystemTrace(parsed.data.systemTraceId)
626
+ try {
627
+ return await withResolvedDevice(device, async () => {
616
628
  const started = performance.now()
617
629
  const results: any[] = []
618
630
  let stoppedAt: string | null = null
619
631
  let pendingLightStep: number | null = null
620
632
  for (let index = 0; index < steps.length; index++) {
621
633
  const step = steps[index]
634
+ if (platform === 'android' && debuggerSessions.paused(deviceId)) {
635
+ 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' } })
636
+ stoppedAt = step.id as string
637
+ break
638
+ }
622
639
  const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full'
623
640
  const next = steps[index + 1]
624
641
  const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
@@ -653,6 +670,7 @@ async function handleRunJourney(args: ToolCallArgs) {
653
670
  } catch (error) {
654
671
  result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
655
672
  }
673
+ if (platform === 'android' && debuggerSessions.paused(deviceId)) { result.success = false; result.failure_code = 'DEBUGGER_PAUSED'; result.retryable = false }
656
674
  if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
657
675
  const success = !!result?.success
658
676
  if (lightCoveredByAssertion) {
@@ -691,8 +709,9 @@ async function handleRunJourney(args: ToolCallArgs) {
691
709
  if (key !== 'total_ms' && typeof value === 'number') componentTiming[key] = (componentTiming[key] ?? 0) + value
692
710
  }
693
711
  refreshJourneyEvidence(results)
694
- 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')
712
+ 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')
695
713
  })
714
+ } finally { await finishTrace?.() }
696
715
  }
697
716
 
698
717
  async function executeSwipe(args: ToolCallArgs) {
@@ -841,7 +860,33 @@ async function handleGetNetworkActivity(args: ToolCallArgs) {
841
860
  return wrapResponse(result)
842
861
  }
843
862
 
863
+ async function handleSystemTrace(name: string, args: ToolCallArgs) {
864
+ const schema = name === 'start_system_trace' ? traceStartSchema : name === 'get_system_trace_capabilities' ? traceTargetSchema : traceControlSchema
865
+ const parsed = schema.safeParse(args)
866
+ if (!parsed.success) throw new TraceError('INVALID_ARGUMENT')
867
+ if (name === 'get_system_trace_capabilities' || name === 'start_system_trace') {
868
+ const target = parsed.data as { platform: PlatformArg; deviceId?: string; durationMs?: number }
869
+ if (target.platform !== 'android') {
870
+ if (name === 'start_system_trace') throw new TraceError('UNSUPPORTED_PLATFORM')
871
+ return wrapResponse({ supported: false, backend: 'perfetto', device: null, prerequisites: {}, reasons: ['UNSUPPORTED_PLATFORM'], enabled_sources: [], unavailable_categories: [], limitations: ['Android only'] })
872
+ }
873
+ let device
874
+ try { device = await resolveTargetDevice(target) }
875
+ catch {
876
+ if (name === 'start_system_trace') throw new TraceError('TRACE_UNSUPPORTED')
877
+ 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'] })
878
+ }
879
+ if (name === 'get_system_trace_capabilities') return wrapResponse(await systemTraces.capabilities(device.id))
880
+ const result = await systemTraces.start(device.id, target.durationMs)
881
+ return { ...wrapResponse(result), ...(result.state === 'failed' ? { isError: true } : {}) }
882
+ }
883
+ const operation = name === 'stop_system_trace' ? 'stop' : name === 'cancel_system_trace' ? 'cancel' : 'status'
884
+ const result = await systemTraces.control((parsed.data as { traceId: string }).traceId, operation)
885
+ return { ...wrapResponse(result), ...(['failed', 'cancelled'].includes(result.state) ? { isError: true } : {}) }
886
+ }
887
+
844
888
  export const toolHandlers: Record<string, ToolHandler> = {
889
+ ...Object.fromEntries(systemTraceTools.map(({ name }) => [name, (args: ToolCallArgs) => handleSystemTrace(name, args)])),
845
890
  get_app_readiness: async args => {
846
891
  const parsed = readinessSchema.safeParse(args)
847
892
  if (!parsed.success) throw new Error('INVALID_ARGUMENT: invalid readiness arguments')
@@ -893,7 +938,8 @@ for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint
893
938
  toolHandlers[name] = correlatedHandler(name, toolHandlers[name], name === 'adjust_control', name === 'run_journey')
894
939
  }
895
940
 
896
- export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
941
+ export async function handleToolCall(name: string, args: ToolCallArgs = {}, signal?: AbortSignal) {
942
+ if (Object.hasOwn(debuggerContracts, name)) return handleDebugger(name, args, signal)
897
943
  const handler = toolHandlers[name]
898
944
  if (!handler) throw new Error(`Unknown tool: ${name}`)
899
945
 
@@ -913,9 +959,48 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
913
959
  }
914
960
  return withTiming(measured, async () => {
915
961
  try {
916
- validateExpectedState(name, args)
917
- return addTiming(await handler(args))
962
+ if (!systemTraceToolNames.has(name)) validateExpectedState(name, args)
963
+ 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)
964
+ if (guarded && args.platform !== 'ios' && debuggerSessions.hasActiveSessions()) {
965
+ const device = await resolveTargetDevice({ platform: 'android', deviceId: args.deviceId as string | undefined })
966
+ return withResolvedDevice(device, () => withDebuggerGuard('android', device.id, async () => {
967
+ const before = debuggerSessions.paused(device.id)
968
+ if (before) return wrapResponse({ success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before })
969
+ let response: ToolCallResult
970
+ try { response = await handler(args) } catch (error) {
971
+ if (!debuggerSessions.paused(device.id)) throw error
972
+ response = wrapResponse({ success: false })
973
+ }
974
+ const pause = debuggerSessions.paused(device.id)
975
+ if (pause) {
976
+ const item = response.content.find(item => item.type === 'text')
977
+ if (item?.text) {
978
+ const payload = JSON.parse(item.text)
979
+ Object.assign(payload, { success: false, failure_code: 'DEBUGGER_PAUSED', retryable: false,
980
+ dispatch_started: guardDispatched(), delivery_status: payload.success ? 'delivered' : guardDispatched() ? 'unknown' : 'not_delivered',
981
+ verification: { status: 'blocked', reason: 'DEBUGGER_PAUSED' }, debugger: pause })
982
+ item.text = JSON.stringify(payload)
983
+ }
984
+ }
985
+ return addTiming(response)
986
+ }))
987
+ }
988
+ const response = await handler(args)
989
+ 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') {
990
+ const item = response.content.find(item => item.type === 'text')
991
+ if (item?.text) {
992
+ try {
993
+ const payload = JSON.parse(item.text)
994
+ const device = payload.device ?? payload.evidence?.device
995
+ const id = device?.id ?? args.deviceId
996
+ const pause = id && device?.platform !== 'ios' ? debuggerSessions.paused(String(id)) : null
997
+ if (pause) item.text = JSON.stringify({ ...payload, debugger: pause })
998
+ } catch { /* Non-JSON observation */ }
999
+ }
1000
+ }
1001
+ return addTiming(response)
918
1002
  } catch (error) {
1003
+ if (error instanceof TraceError) return { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true }
919
1004
  console.error(`Error executing tool ${name}:`, error)
920
1005
  return addTiming(wrapToolError(name, error))
921
1006
  }
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
16
16
 
17
17
  export const serverInfo = {
18
18
  name: 'mobile-debug-mcp',
19
- version: '0.33.0'
19
+ version: '0.34.0'
20
20
  }
21
21
 
22
22
  export function createServer() {
@@ -46,9 +46,9 @@ export function createServer() {
46
46
  tools: toolDefinitions
47
47
  }))
48
48
 
49
- server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema>) => {
49
+ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema>, extra) => {
50
50
  const { name, arguments: args } = request.params
51
- return handleToolCall(name, args as Record<string, unknown>)
51
+ return handleToolCall(name, args as Record<string, unknown>, extra.signal)
52
52
  })
53
53
 
54
54
  return server
package/src/server.ts 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'
@@ -13,6 +15,28 @@ if (process.env.MOBILE_DEBUG_MCP_STARTUP_HEALTHCHECK === '1') {
13
15
 
14
16
  const transport = new StdioServerTransport()
15
17
 
18
+ let shuttingDown = false
19
+ async function shutdown() {
20
+ if (shuttingDown) return
21
+ shuttingDown = true
22
+ const deadline = setTimeout(() => { console.error('System trace shutdown cleanup timed out; owned device captures remain duration-bounded.'); process.exit(1) }, 10000)
23
+ let exitCode = 0
24
+ try {
25
+ const [remaining, debuggers] = await Promise.all([systemTraces.shutdown(), debuggerSessions.shutdown()])
26
+ if (debuggers.length) { exitCode = 1; console.error('Debugger shutdown cleanup incomplete', debuggers.map(s => s.cleanup)) }
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
+ } catch { exitCode = 1; console.error('Server shutdown cleanup failed.') }
33
+ finally { clearTimeout(deadline); process.exit(exitCode) }
34
+ }
35
+ server.onclose = () => { void shutdown() }
36
+ process.stdin.on('end', () => { void shutdown() })
37
+ process.on('SIGINT', () => { void shutdown() })
38
+ process.on('SIGTERM', () => { void shutdown() })
39
+
16
40
  async function main() {
17
41
  await (server as any).connect(transport)
18
42
  }
@@ -33,6 +33,7 @@ interface ToolEntry {
33
33
  }
34
34
 
35
35
  interface CapabilityEntry {
36
+ probeRequired?: string
36
37
  availability: boolean
37
38
  selectedProvider: string | null
38
39
  blockingFailure: DiagnosticFailure | null
@@ -41,7 +42,7 @@ interface CapabilityEntry {
41
42
  interface PlatformEntry {
42
43
  status: PlatformStatus
43
44
  tools: Record<string, ToolEntry>
44
- providers: Record<string, { available: boolean; selected: boolean; tool: string | null }>
45
+ providers: Record<string, { available: boolean; selected: boolean; tool: string | null; probeRequired?: string }>
45
46
  capabilities: Record<string, CapabilityEntry>
46
47
  devices: Array<Record<string, unknown>>
47
48
  failures: DiagnosticFailure[]
@@ -289,7 +290,7 @@ function buildAndroidPlatform(android: Awaited<ReturnType<typeof checkAndroid>>,
289
290
  },
290
291
  providers: {
291
292
  adb: { available: adbAvailable, selected: true, tool: 'adb' },
292
- jdwp: { available: adbAvailable && android.devices > 0, selected: false, tool: 'adb' }
293
+ jdwp: { available: false, selected: false, tool: 'adb', probeRequired: 'get_debugger_capabilities' }
293
294
  },
294
295
  capabilities: {
295
296
  device_discovery: {
@@ -323,9 +324,10 @@ function buildAndroidPlatform(android: Awaited<ReturnType<typeof checkAndroid>>,
323
324
  blockingFailure: logAvailable ? null : adbFailure ?? envFailure
324
325
  },
325
326
  debug: {
326
- availability: adbAvailable && android.devices > 0,
327
- selectedProvider: adbAvailable && android.devices > 0 ? 'JDWP' : null,
328
- blockingFailure: adbAvailable && android.devices > 0 ? null : deviceFailure ?? adbFailure ?? envFailure
327
+ probeRequired: 'get_debugger_capabilities',
328
+ availability: false,
329
+ selectedProvider: null,
330
+ blockingFailure: deviceFailure ?? adbFailure ?? envFailure
329
331
  }
330
332
  },
331
333
  devices: deviceEntries,
package/src/utils/exec.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
1
2
  import { remainingBudget } from './operation-budget.js'
2
3
  import { spawn } from 'child_process'
3
4
 
@@ -7,6 +8,7 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
7
8
  const { env, cwd, shell } = opts
8
9
  const timeout = remainingBudget(opts.timeout ?? 0)
9
10
  return new Promise((resolve, reject) => {
11
+ checkDebuggerGuard()
10
12
  const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
11
13
  let stdout = ''
12
14
  let stderr = ''
package/src/utils/java.ts CHANGED
@@ -21,17 +21,18 @@ function javaVersionOf(javaBin: string): string | undefined {
21
21
  } catch (e: unknown) { console.debug('[javaVersionOf] java -version failed: ' + String(e)); return undefined }
22
22
  }
23
23
 
24
- export async function detectJavaHome(): Promise<string | undefined> {
24
+ export async function detectJavaHome(options: { quiet?: boolean } = {}): Promise<string | undefined> {
25
+ const debug = (...args: unknown[]) => { if (!options.quiet) console.debug(...args) }
25
26
  try {
26
27
  // 1) Honor explicit ANDROID_STUDIO_JDK env (highest priority)
27
28
  const envStudio = process.env.ANDROID_STUDIO_JDK || process.env.ANDROID_STUDIO_JBR
28
29
  if (envStudio && existsSync(path.join(envStudio, 'bin', 'java'))) {
29
30
  const v = javaVersionOf(path.join(envStudio, 'bin', 'java'))
30
31
  if (isJavaVersionAcceptable(v)) {
31
- console.debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio)
32
+ debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio)
32
33
  return envStudio
33
34
  }
34
- console.debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions')
35
+ debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions')
35
36
  }
36
37
 
37
38
  // 2) Android Studio JBR candidates (prefer these over JAVA_HOME)
@@ -50,7 +51,7 @@ export async function detectJavaHome(): Promise<string | undefined> {
50
51
  if (existsSync(javaBin)) {
51
52
  const v = javaVersionOf(javaBin)
52
53
  if (isJavaVersionAcceptable(v)) {
53
- console.debug('[java.detect] Found Android Studio JBR at:', p)
54
+ debug('[java.detect] Found Android Studio JBR at:', p)
54
55
  return p
55
56
  }
56
57
  }
@@ -62,12 +63,12 @@ export async function detectJavaHome(): Promise<string | undefined> {
62
63
  const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java')
63
64
  const v = javaVersionOf(javaBin)
64
65
  if (isJavaVersionAcceptable(v)) {
65
- console.debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME)
66
+ debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME)
66
67
  return process.env.JAVA_HOME
67
68
  }
68
- console.debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search')
69
+ debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search')
69
70
  } catch {
70
- console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK')
71
+ debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK')
71
72
  }
72
73
  }
73
74
 
@@ -79,11 +80,11 @@ export async function detectJavaHome(): Promise<string | undefined> {
79
80
  try {
80
81
  const out17 = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
81
82
  if (out17) return out17
82
- } catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e)) }
83
+ } catch (e: unknown) { debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e)) }
83
84
  try {
84
85
  const out21 = execSync('/usr/libexec/java_home -v 21', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
85
86
  if (out21) return out21
86
- } catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e)) }
87
+ } catch (e: unknown) { debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e)) }
87
88
 
88
89
  // 6) macOS common JDK locations
89
90
  try {
@@ -94,7 +95,7 @@ export async function detectJavaHome(): Promise<string | undefined> {
94
95
  return candidate
95
96
  }
96
97
  }
97
- } catch (e: unknown) { console.debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e)) }
98
+ } catch (e: unknown) { debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e)) }
98
99
 
99
100
  // 7) Linux locations
100
101
  const linuxCandidates = [
@@ -106,10 +107,10 @@ export async function detectJavaHome(): Promise<string | undefined> {
106
107
  '/usr/lib/jvm/java-21-openjdk-amd64'
107
108
  ]
108
109
  for (const p of linuxCandidates) {
109
- try { if (existsSync(p)) return p } catch (e: unknown) { console.debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`) }
110
+ try { if (existsSync(p)) return p } catch (e: unknown) { debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`) }
110
111
  }
111
112
  } catch (e: unknown) {
112
- console.debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e))
113
+ debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e))
113
114
  }
114
115
  return undefined
115
116
  }
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
1
2
  import { AsyncLocalStorage } from 'node:async_hooks'
2
3
 
3
4
  export interface OperationBudget { deadline: number; dispatchStarted: boolean; parent?: OperationBudget }
@@ -36,6 +37,7 @@ export async function runWithSeparateBudget<T>(timeoutMs: number, operation: ()
36
37
  }
37
38
  }
38
39
  export function markDispatchStarted() {
40
+ checkDebuggerGuard(true)
39
41
  remainingBudget()
40
42
  let budget = budgets.getStore()
41
43
  while (budget) { budget.dispatchStarted = true; budget = budget.parent }
@@ -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'
@@ -56,6 +57,7 @@ export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promi
56
57
  }
57
58
 
58
59
  export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceInfo> {
60
+ checkDebuggerGuard()
59
61
  const device = await resolveDevice(opts)
60
62
  bindEvidenceDevice(device)
61
63
  return device
@@ -0,0 +1,203 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync, spawn } from 'node:child_process'
3
+ import { readFile, readdir, rm, writeFile } from 'node:fs/promises'
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
6
+ import { createHash } from 'node:crypto'
7
+ import { buildDebuggerFixture, debuggerAppId } from '../../../fixtures/debugger-app/build.js'
8
+
9
+ // Explicit opt-in: installs/removes only this isolated fixture on the selected device.
10
+ if (!process.env.MCP_DEBUGGER_DEVICE) {
11
+ console.log('UNRUN Android debugger fixture: set MCP_DEBUGGER_DEVICE to an authorized emulator serial')
12
+ } else {
13
+ const deviceId = process.env.MCP_DEBUGGER_DEVICE
14
+ const adb = (...args: string[]) => execFileSync(process.env.ADB_PATH || 'adb', ['-s', deviceId, ...args], { encoding: 'utf8', timeout: 15000 })
15
+ const client = new Client({ name: 'android-debugger-smoke', version: '1.0.0' })
16
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
17
+ const call = async (name: string, args: Record<string, unknown>) => {
18
+ const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 45000 })
19
+ const text = (response.content as any[]).find(c => c.type === 'text')!.text!
20
+ return JSON.parse(text)
21
+ }
22
+ const hash = createHash('sha256')
23
+ const fingerprint = async (directory: string): Promise<void> => {
24
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
25
+ const file = directory + '/' + entry.name
26
+ if (entry.isDirectory()) await fingerprint(file)
27
+ else hash.update(file).update(await readFile(file))
28
+ }
29
+ }
30
+ await fingerprint('dist'); await fingerprint('worker')
31
+ const buildHash = hash.digest('hex')
32
+ const fixture = buildDebuggerFixture()
33
+ const source = await readFile(new URL('../../../fixtures/debugger-app/MainActivity.java', import.meta.url), 'utf8')
34
+ const line = (marker: string) => source.split('\n').findIndex(l => l.includes(marker)) + 1
35
+ let debugSessionId = ''
36
+ let cursor = 0
37
+ const eventsUntilStop = async () => {
38
+ for (let i = 0; i < 10; i++) {
39
+ const result = await call('wait_for_debugger_event', { debugSessionId, afterSequence: cursor, timeoutMs: 1000 })
40
+ cursor = result.next_cursor
41
+ if (result.events.length) console.log('Debug events', JSON.stringify(result.events))
42
+ const event = result.events.find((e: any) => e.event === 'stopped')
43
+ if (event) return event
44
+ }
45
+ throw new Error('No debugger stop observed')
46
+ }
47
+ const forwardsBefore = adb('forward', '--list').trim()
48
+ try {
49
+ await client.connect(transport)
50
+ const listed = await client.listTools()
51
+ assert.ok(listed.tools.some(tool => tool.name === 'attach_debugger'))
52
+ adb('install', '-r', fixture.artifact)
53
+ adb('shell', 'am', 'start', '-W', '-n', `${debuggerAppId}/.MainActivity`)
54
+ const cap = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: debuggerAppId })
55
+ assert.equal(cap.supported, true, JSON.stringify(cap))
56
+ const timeout = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 100 })
57
+ assert.equal(timeout.error.code, 'DEBUGGER_TIMEOUT')
58
+ const unsupported = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: 'com.missing.debugfixture' })
59
+ assert.equal(unsupported.supported, false)
60
+ assert.ok(unsupported.reasons.includes('PROCESS_NOT_FOUND'))
61
+ const attached = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
62
+ assert.equal(attached.success, true, JSON.stringify(attached)); debugSessionId = attached.debug_session_id
63
+ const bp = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: line('BREAK_CALCULATE') })
64
+ assert.equal(bp.result.status, 'verified', JSON.stringify(bp))
65
+ const pending = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.LateWork`, line: line('BREAK_LATE') })
66
+ assert.equal(pending.result.status, 'pending', JSON.stringify(pending))
67
+ const unavailable = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: 9999 })
68
+ assert.equal(unavailable.result.status, 'rejected')
69
+ // Fixture button placement is obtained from real UI semantics, not fixed coordinates.
70
+ const tree = await call('get_ui_tree', { platform: 'android', deviceId })
71
+ const button = tree.elements.find((e: any) => /^calculate$/i.test(e.text ?? ''))
72
+ assert.ok(button, 'Calculate button visible in real UI tree: ' + JSON.stringify(tree))
73
+ console.log('Fixture target', JSON.stringify({ bounds: button.bounds, device: tree.device, resolution: tree.resolution }))
74
+ const [left, top, right, bottom] = button.bounds
75
+ // Shell input is asynchronous so suspension cannot block the test's next debugger call.
76
+ await writeFile(fixture.directory + '/fixture.png', execFileSync(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'exec-out', 'screencap', '-p']))
77
+ const tap = spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((left + right) / 2)), String(Math.floor((top + bottom) / 2))], { stdio: 'ignore' })
78
+ tap.on('error', error => { throw error })
79
+ const stop = await eventsUntilStop()
80
+ assert.equal(stop.stops[0].location.line, line('BREAK_CALCULATE'))
81
+ const stack = await call('get_debugger_stack', { debugSessionId, stopId: stop.stop_id, threadId: stop.thread_id })
82
+ assert.equal(stack.result.frames[0].location.method, 'calculate')
83
+ const frameId = stack.result.frames[0].frame_id
84
+ const locals = await call('get_debugger_locals', { debugSessionId, stopId: stop.stop_id, frameId })
85
+ assert.equal(locals.result.locals.find((v: any) => v.name === 'known')?.value, '42', JSON.stringify(locals))
86
+ const longText = locals.result.locals.find((v: any) => v.name === 'longText')
87
+ assert.equal(longText.value.length, 256); assert.equal(longText.truncated, true)
88
+ const object = locals.result.locals.find((v: any) => v.name === 'object')
89
+ assert.equal(object.value.type, 'java.lang.Object'); assert.equal(typeof object.value.object_id, 'string')
90
+ const readiness = await call('get_app_readiness', { platform: 'android', deviceId })
91
+ assert.equal(readiness.readiness.state, 'debugger_paused')
92
+ for (const name of ['tap', 'wait_for_ui', 'terminate_app', 'run_journey']) {
93
+ const blocked = await call(name, { platform: 'android', deviceId, x: 5, y: 5, appId: debuggerAppId })
94
+ assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED', name)
95
+ assert.equal(blocked.dispatch_started, false)
96
+ }
97
+ await call('remove_debugger_breakpoint', { debugSessionId, breakpointId: bp.result.breakpoint_id })
98
+ const step = await call('step_debugger', { debugSessionId, stopId: stop.stop_id, threadId: stop.thread_id, kind: 'into' })
99
+ assert.equal(step.success, true, JSON.stringify(step))
100
+ const stale = await call('get_debugger_locals', { debugSessionId, stopId: stop.stop_id, frameId })
101
+ assert.equal(stale.error.code, 'DEBUGGER_STALE_REFERENCE')
102
+ const into = await eventsUntilStop()
103
+ assert.equal(into.stops[0].location.method, 'increment')
104
+ assert.equal((await call('step_debugger', { debugSessionId, stopId: into.stop_id, threadId: into.thread_id, kind: 'over' })).success, true)
105
+ const over = await eventsUntilStop()
106
+ assert.equal(over.stops[0].location.method, 'increment')
107
+ assert.equal((await call('step_debugger', { debugSessionId, stopId: over.stop_id, threadId: over.thread_id, kind: 'out' })).success, true)
108
+ const out = await eventsUntilStop()
109
+ assert.equal(out.stops[0].location.method, 'calculate')
110
+ assert.equal((await call('resume_debugger', { debugSessionId, stopId: out.stop_id })).success, true)
111
+ let resultVisible = false
112
+ for (let i = 0; i < 10; i++) {
113
+ const observed = await call('get_ui_tree', { platform: 'android', deviceId })
114
+ if (observed.elements.some((e: any) => e.text === 'Result 43')) { resultVisible = true; break }
115
+ }
116
+ assert.ok(resultVisible, 'UI tree independently establishes resumed execution: Result 43')
117
+ const noLocalsSource = await readFile(new URL('../../../fixtures/debugger-app/NoLocals.java', import.meta.url), 'utf8')
118
+ const noLocalsLine = noLocalsSource.split('\n').findIndex(line => line.includes('BREAK_NO_LOCALS')) + 1
119
+ const removedPending = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.NoLocals`, line: noLocalsLine })
120
+ await call('remove_debugger_breakpoint', { debugSessionId, breakpointId: removedPending.result.breakpoint_id })
121
+ await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.NoLocals`, line: noLocalsLine })
122
+ const metadataTree = await call('get_ui_tree', { platform: 'android', deviceId })
123
+ const mb = metadataTree.elements.find((e: any) => /^no locals$/i.test(e.text ?? '')).bounds
124
+ spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((mb[0] + mb[2]) / 2)), String(Math.floor((mb[1] + mb[3]) / 2))], { stdio: 'ignore' })
125
+ const metadataStop = await eventsUntilStop()
126
+ assert.ok(metadataStop.stops.every((stop: any) => stop.breakpoint_id !== removedPending.result.breakpoint_id), 'removed pending breakpoint never rebinds')
127
+ const metadataStack = await call('get_debugger_stack', { debugSessionId, stopId: metadataStop.stop_id, threadId: metadataStop.thread_id })
128
+ const metadataLocals = await call('get_debugger_locals', { debugSessionId, stopId: metadataStop.stop_id, frameId: metadataStack.result.frames[0].frame_id })
129
+ assert.equal(metadataLocals.result.availability, 'unknown')
130
+ assert.equal(metadataLocals.result.reason, 'EMPTY_OR_UNAVAILABLE_VARIABLE_METADATA')
131
+ await call('resume_debugger', { debugSessionId, stopId: metadataStop.stop_id })
132
+ const lateTree = await call('get_ui_tree', { platform: 'android', deviceId })
133
+ const lateButton = lateTree.elements.find((e: any) => /^late class$/i.test(e.text ?? ''))
134
+ assert.ok(lateButton)
135
+ const bounds = lateButton.bounds
136
+ spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((bounds[0] + bounds[2]) / 2)), String(Math.floor((bounds[1] + bounds[3]) / 2))], { stdio: 'ignore' })
137
+ const lateStop = await eventsUntilStop()
138
+ assert.equal(lateStop.stops[0].location.class_name, `${debuggerAppId}.LateWork`)
139
+ assert.equal(lateStop.stops[0].location.line, line('BREAK_LATE'))
140
+ const aborted = new AbortController()
141
+ const cancelled = client.callTool({ name: 'wait_for_debugger_event', arguments: { debugSessionId, afterSequence: cursor, timeoutMs: 1000 } }, undefined, { signal: aborted.signal, timeout: 5000 })
142
+ const cancelTimer = setTimeout(() => aborted.abort(), 100)
143
+ try { await assert.rejects(cancelled) } finally { clearTimeout(cancelTimer) }
144
+ assert.equal((await call('get_debugger_state', { debugSessionId })).state, 'paused')
145
+ if (process.env.MCP_DEBUGGER_EXTENDED === '1') {
146
+ console.log('Checking real 60-second pause expiry while polling state')
147
+ const deadline = Date.now() + 65000
148
+ let state: any
149
+ do {
150
+ await new Promise(resolve => setTimeout(resolve, 1000))
151
+ state = await call('get_debugger_state', { debugSessionId })
152
+ } while (state.state === 'paused' && Date.now() < deadline)
153
+ assert.equal(state.state, 'detached', JSON.stringify(state))
154
+ assert.equal(state.reason, 'pause_limit')
155
+ assert.equal(state.cleanup.status, 'complete')
156
+ console.log('PASS real pause expiry despite active client polling')
157
+ }
158
+ const detach = await call('detach_debugger', { debugSessionId })
159
+ assert.equal(detach.cleanup.status, 'complete', JSON.stringify(detach))
160
+ assert.equal((await call('detach_debugger', { debugSessionId })).cleanup.status, 'complete')
161
+ assert.equal(adb('forward', '--list').trim(), forwardsBefore, 'unrelated forwards preserved')
162
+ const reattached = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
163
+ assert.equal(reattached.success, true, JSON.stringify(reattached))
164
+ debugSessionId = reattached.debug_session_id
165
+ adb('shell', 'am', 'force-stop', debuggerAppId)
166
+ let disconnected: any
167
+ for (let i = 0; i < 20; i++) {
168
+ disconnected = await call('get_debugger_state', { debugSessionId })
169
+ if (disconnected.cleanup.status === 'complete') break
170
+ await new Promise(resolve => setTimeout(resolve, 100))
171
+ }
172
+ assert.equal(disconnected.state, 'disconnected', JSON.stringify(disconnected))
173
+ assert.equal(disconnected.cleanup.status, 'complete')
174
+ assert.equal(adb('forward', '--list').trim(), forwardsBefore)
175
+ // Disconnect the MCP client with an actively paused session; shutdown owns resume/dispose.
176
+ adb('shell', 'am', 'start', '-W', '-n', `${debuggerAppId}/.MainActivity`)
177
+ const shutdownSession = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
178
+ assert.equal(shutdownSession.success, true, JSON.stringify(shutdownSession))
179
+ debugSessionId = shutdownSession.debug_session_id; cursor = 0
180
+ await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: line('BREAK_CALCULATE') })
181
+ const shutdownTree = await call('get_ui_tree', { platform: 'android', deviceId })
182
+ const shutdownButton = shutdownTree.elements.find((e: any) => /^calculate$/i.test(e.text ?? ''))
183
+ const sb = shutdownButton.bounds
184
+ spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((sb[0] + sb[2]) / 2)), String(Math.floor((sb[1] + sb[3]) / 2))], { stdio: 'ignore' })
185
+ await eventsUntilStop()
186
+ await client.close(); debugSessionId = ''
187
+ assert.equal(adb('forward', '--list').trim(), forwardsBefore, 'MCP disconnect cleans active owned forward')
188
+ const verifier = new Client({ name: 'debugger-shutdown-verifier', version: '1.0.0' })
189
+ try {
190
+ await verifier.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' }))
191
+ const result = await verifier.callTool({ name: 'get_ui_tree', arguments: { platform: 'android', deviceId } })
192
+ const treeAfterShutdown = JSON.parse((result.content as any[]).find(c => c.type === 'text').text)
193
+ assert.ok(treeAfterShutdown.elements.some((e: any) => e.text === 'Result 43'), 'fresh UI proves paused fixture resumed after server shutdown')
194
+ } finally { await verifier.close() }
195
+ console.log(JSON.stringify({ status: 'PASS', build_sha256: buildHash, device: deviceId, api: adb('shell', 'getprop', 'ro.build.version.sdk').trim(), runtime: process.version, transport: 'MCP stdio', extended: process.env.MCP_DEBUGGER_EXTENDED === '1' }))
196
+ console.log('PASS Android debugger: explicit attach, source stop, stack/known locals, pause guards, into/over/out, stale frame, resume UI, detach/forward ownership')
197
+ } finally {
198
+ if (debugSessionId) await call('detach_debugger', { debugSessionId }).catch(() => {})
199
+ await client.close()
200
+ adb('uninstall', debuggerAppId)
201
+ await rm(fixture.directory, { recursive: true, force: true })
202
+ }
203
+ }