mobile-debug-mcp 0.35.0 → 0.36.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 (112) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +86 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +253 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +36 -16
  19. package/dist/debugger/guard.js +4 -4
  20. package/dist/debugger/ios-worker.js +62 -0
  21. package/dist/debugger/ios.js +57 -0
  22. package/dist/debugger/sessions.js +33 -16
  23. package/dist/debugger/worker.js +6 -4
  24. package/dist/interact/index.js +12 -2
  25. package/dist/manage/ios.js +2 -0
  26. package/dist/observe/ios-trace/artifact.js +63 -0
  27. package/dist/observe/ios-trace/capabilities.js +86 -0
  28. package/dist/observe/ios-trace/native.js +93 -0
  29. package/dist/observe/ios-trace/notification.js +35 -0
  30. package/dist/observe/ios-trace/process.js +98 -0
  31. package/dist/observe/ios-trace/sessions.js +244 -0
  32. package/dist/observe/readiness-service.js +1 -1
  33. package/dist/observe/readiness.js +4 -4
  34. package/dist/observe/system-trace-error.js +7 -0
  35. package/dist/observe/system-trace-router.js +81 -0
  36. package/dist/observe/system-trace.js +4 -8
  37. package/dist/server/handlers/debugger-handlers.js +27 -8
  38. package/dist/server/handlers/diagnostic.js +12 -4
  39. package/dist/server/handlers/tool-handlers.js +19 -9
  40. package/dist/server/schemas/debugger-schema.js +10 -7
  41. package/dist/server/schemas/system-trace-schema.js +14 -9
  42. package/dist/server-core.js +1 -1
  43. package/dist/server.js +4 -4
  44. package/dist/utils/ios/utils.js +3 -0
  45. package/docs/CHANGELOG.md +23 -1
  46. package/docs/agent-plugin-installation-verification.md +39 -0
  47. package/docs/code-structure.md +3 -1
  48. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  49. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  50. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  51. package/docs/specs/031-ios-system-traces/plan.md +138 -0
  52. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  53. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  54. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  55. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  56. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  57. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  58. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  59. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  60. package/docs/tools/TOOLS.md +2 -2
  61. package/docs/tools/debugger.md +45 -5
  62. package/docs/tools/system-traces.md +81 -14
  63. package/package.json +2 -1
  64. package/scripts/agent-plugin.mjs +55 -5
  65. package/src/debugger/android.ts +1 -1
  66. package/src/debugger/guard.ts +5 -5
  67. package/src/debugger/ios-worker.ts +49 -0
  68. package/src/debugger/ios.ts +45 -0
  69. package/src/debugger/lldb/breakpoints.py +51 -0
  70. package/src/debugger/lldb/inspection.py +109 -0
  71. package/src/debugger/lldb/worker.py +203 -0
  72. package/src/debugger/sessions.ts +24 -12
  73. package/src/debugger/worker.ts +7 -4
  74. package/src/interact/index.ts +13 -2
  75. package/src/manage/ios.ts +2 -0
  76. package/src/observe/ios-trace/artifact.ts +55 -0
  77. package/src/observe/ios-trace/capabilities.ts +74 -0
  78. package/src/observe/ios-trace/native.ts +72 -0
  79. package/src/observe/ios-trace/notification.ts +30 -0
  80. package/src/observe/ios-trace/process.ts +82 -0
  81. package/src/observe/ios-trace/sessions.ts +204 -0
  82. package/src/observe/readiness-service.ts +1 -1
  83. package/src/observe/readiness.ts +4 -4
  84. package/src/observe/system-trace-error.ts +3 -0
  85. package/src/observe/system-trace-router.ts +61 -0
  86. package/src/observe/system-trace.ts +4 -4
  87. package/src/server/handlers/debugger-handlers.ts +28 -9
  88. package/src/server/handlers/diagnostic.ts +11 -4
  89. package/src/server/handlers/tool-handlers.ts +20 -9
  90. package/src/server/schemas/debugger-schema.ts +10 -7
  91. package/src/server/schemas/system-trace-schema.ts +15 -9
  92. package/src/server-core.ts +1 -1
  93. package/src/server.ts +3 -3
  94. package/src/utils/ios/utils.ts +3 -0
  95. package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
  96. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  97. package/test/fixtures/contracts/tool-definitions.json +192 -32
  98. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  99. package/test/fixtures/ios-debugger-app/README.md +27 -0
  100. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  101. package/test/helpers/ios-trace-shutdown.ts +29 -0
  102. package/test/unit/debugger/handle-guards.test.ts +88 -0
  103. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  104. package/test/unit/debugger/ios.test.ts +104 -0
  105. package/test/unit/debugger/lldb-worker.py +166 -0
  106. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  107. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  108. package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
  109. package/test/unit/observe/readiness.test.ts +1 -1
  110. package/test/unit/observe/system-trace-router.test.ts +49 -0
  111. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  112. package/test/unit/server/system-trace.test.ts +12 -3
@@ -0,0 +1,119 @@
1
+ import { verifyIosTraceShutdown } from '../../../helpers/ios-trace-shutdown.js'
2
+ import assert from 'node:assert/strict'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { promises as fs } from 'node:fs'
5
+ import path from 'node:path'
6
+ import { createHash } from 'node:crypto'
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
8
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
9
+ import { buildReadinessFixture, readinessAppId } from '../../../fixtures/readiness-app/build.js'
10
+
11
+ const inputEnvironment = () => Object.fromEntries(['IDB_COMPANION','MCP_IDB_PATH','IDB_PATH','XCRUN_PATH'].flatMap(key=>process.env[key]?[[key,process.env[key]!]]:[]))
12
+ async function run() {
13
+ const deviceId=process.env.IOS_SYSTEM_TRACE_DEVICE
14
+ if(process.env.SKIP_DEVICE_TESTS==='1'||!deviceId){console.log('iOS system trace smoke: UNRUN (set IOS_SYSTEM_TRACE_DEVICE to an explicit simulator UDID)');return}
15
+ assert.equal(process.platform,'darwin')
16
+ const command=(args:string[])=>execFileSync('xcrun',args,{encoding:'utf8',timeout:120000,stdio:'pipe'})
17
+ const sims=JSON.parse(command(['simctl','list','devices','--json']))
18
+ const device=Object.values(sims.devices).flat().find((d:any)=>d.udid===deviceId) as any
19
+ assert.ok(device?.isAvailable,'Explicit available simulator required')
20
+ let booted=false,installed=false,fixture:ReturnType<typeof buildReadinessFixture>|undefined
21
+ const client=new Client({name:'ios-system-trace-smoke',version:'1.0.0'})
22
+ const transport=new StdioClientTransport({command:process.execPath,args:['dist/server.js'],stderr:'pipe',env:inputEnvironment()})
23
+ const owned=new Set<string>(),artifacts:string[]=[],checks:Record<string,unknown>={}
24
+ let journeyFailure: Error | undefined
25
+ const target={platform:'ios',deviceId},traceTarget={...target,appId:readinessAppId}
26
+ const call=async(name:string,args:Record<string,unknown>)=>{
27
+ const result=await client.callTool({name,arguments:args},undefined,{timeout:180000})
28
+ return JSON.parse((result.content as any[]).find(c=>c.type==='text').text)
29
+ }
30
+ const start=async(durationMs=90000)=>{
31
+ const r=await call('start_system_trace',{...traceTarget,durationMs})
32
+ if(r.trace_id&&r.cleanup?.status!=='complete')owned.add(r.trace_id)
33
+ assert.equal(r.state,'recording',JSON.stringify(r));assert.ok(r.recording_ready_at_ms>=r.capture_started_at_ms)
34
+ return r
35
+ }
36
+ const stop=async(id:string)=>{
37
+ const r=await call('stop_system_trace',{traceId:id})
38
+ if(r.artifact)artifacts.push(r.artifact.path)
39
+ assert.equal(r.state,'completed',JSON.stringify(r));assert.equal(r.cleanup.status,'complete')
40
+ assert.equal(r.artifact.validation.readable,true);owned.delete(id);return r
41
+ }
42
+ try{
43
+ if(device.state!=='Booted'){command(['simctl','boot',deviceId]);booted=true;command(['simctl','bootstatus',deviceId,'-b'])}
44
+ let preexisting=false
45
+ try{const existing=command(['simctl','get_app_container',deviceId,readinessAppId,'app']).trim();await fs.stat(existing);preexisting=true}catch{/* absent or stale simulator registration */}
46
+ assert.equal(preexisting,false,'Refuse to replace a pre-existing fixture')
47
+ fixture=buildReadinessFixture('ios');command(['simctl','install',deviceId,fixture.artifact]);installed=true
48
+ command(['simctl','launch',deviceId,readinessAppId])
49
+ await client.connect(transport)
50
+ const cap=await call('get_system_trace_capabilities',traceTarget)
51
+ assert.equal(cap.supported,true,JSON.stringify(cap));assert.equal(cap.prerequisites.permissions,'unknown')
52
+ const tree=await call('get_ui_tree',target)
53
+ assert.ok(tree.elements.some((e:any)=>e.text==='Ready'),'Fixture must be semantically visible')
54
+ assert.ok(tree.elements.some((e:any)=>e.text==='Show loader'),'Fixture button must be semantically visible')
55
+ const recording=await start()
56
+ const baselineJourney=await call('run_journey',{...target,systemTraceId:recording.trace_id,steps:[{id:'ready',type:'assert',timeoutMs:10000,assertion:{kind:'element_visible',selector:{text:'Ready'}}}]})
57
+ assert.equal(baselineJourney.success,true,JSON.stringify(baselineJourney));checks.linked_baseline='PASS; Ready asserted via built-server journey'
58
+ const journeySubmitted=Date.now()
59
+ const journey=await call('run_journey',{...target,systemTraceId:recording.trace_id,steps:[
60
+ {id:'show-loader',type:'tap',selector:{text:'Show loader'},verificationMode:'none'},
61
+ {id:'done',type:'assert',timeoutMs:30000,assertion:{kind:'element_visible',selector:{text:'Done'}}}
62
+ ]})
63
+ if (!journey.success) journeyFailure = new Error(`Traced fixture input journey failed at ${journey.stopped_at_step_id}; ${journey.steps[0].result?.failure_code}`)
64
+ else assert.equal(journey.steps[1].status,'passed')
65
+ assert.equal(journey.evidence.trace_context.trace_id,recording.trace_id)
66
+ assert.equal(journey.steps[0].result.evidence.trace_context.trace_id,recording.trace_id)
67
+ assert.equal(journey.system_trace.coverage,'covered');assert.ok(recording.recording_ready_at_ms<=journeySubmitted)
68
+ const completed=await stop(recording.trace_id)
69
+ assert.equal(completed.device.id,deviceId);assert.equal(completed.app_id,readinessAppId)
70
+ assert.deepEqual(completed.correlation_ids,[baselineJourney.evidence.correlation_id,journey.evidence.correlation_id]);assert.ok(completed.artifact.validation.sample_rows>0)
71
+ checks.journey={status:journey.success?'PASS':'FAIL',trace_id:recording.trace_id,correlation_id:journey.evidence.correlation_id,
72
+ ready_at_ms:recording.recording_ready_at_ms,journey_submitted_at_ms:journeySubmitted,outcome:journey.success?'Done visible via journey UI-tree assertion':journeyFailure?.message,
73
+ sample_rows:completed.artifact.validation.sample_rows,artifact_bytes:completed.artifact.size_bytes,warnings:completed.warnings}
74
+ const stale=await call('run_journey',{...target,systemTraceId:recording.trace_id,steps:[{id:'must-not-tap',type:'tap',selector:{text:'Show loader'},verificationMode:'none'}]})
75
+ assert.equal(stale.error.code,'TRACE_NOT_FOUND')
76
+ const unchanged=await call('expect_element_visible',{...target,selector:{text:journey.success?'Done':'Ready'},timeoutMs:10000})
77
+ assert.ok(unchanged.success||unchanged.visible||unchanged.status==='success',JSON.stringify(unchanged))
78
+ checks.stale_id='PASS; rejected tap left prior fixture label visible'
79
+ const cancelled=await start(30000),directory=cancelled.cleanup.remaining_resources[0]
80
+ const cancel=await call('cancel_system_trace',{traceId:cancelled.trace_id})
81
+ assert.equal(cancel.state,'cancelled');assert.equal(cancel.cleanup.status,'complete');assert.equal(cancel.artifact,null);owned.delete(cancelled.trace_id)
82
+ await assert.rejects(()=>fs.stat(directory),{code:'ENOENT'});checks.cancel='PASS'
83
+ const short=await start(5000)
84
+ let expired=short
85
+ for(let i=0;i<90&&expired.state==='recording';i++){
86
+ await new Promise(r=>setTimeout(r,500));expired=await call('get_system_trace_status',{traceId:short.trace_id})
87
+ }
88
+ if(expired.artifact)artifacts.push(expired.artifact.path)
89
+ assert.equal(expired.state,'completed',JSON.stringify(expired));assert.equal(expired.completion_reason,'duration_limit');assert.equal(expired.cleanup.status,'complete');owned.delete(short.trace_id);checks.expiry='PASS'
90
+ const failedRecording=await start(60000)
91
+ const failedJourney=await call('run_journey',{...target,systemTraceId:failedRecording.trace_id,steps:[{id:'missing',type:'assert',timeoutMs:1000,assertion:{kind:'element_visible',selector:{text:'impossible-trace-fixture-label'}}}]})
92
+ assert.equal(failedJourney.success,false)
93
+ assert.equal((await call('get_system_trace_status',{traceId:failedRecording.trace_id})).state,'recording')
94
+ await stop(failedRecording.trace_id);checks.failed_journey='PASS; failure retained capture for explicit stop'
95
+ await verifyIosTraceShutdown(traceTarget,inputEnvironment());checks.shutdown='PASS; owned recording and partial directory removed'
96
+ const live=command(['simctl','spawn',deviceId,'launchctl','list'])
97
+ assert.ok(live.includes(`UIKitApplication:${readinessAppId}[`),'Recorder cleanup must leave the fixture app running')
98
+ checks.app_survived_cleanup='PASS'
99
+ const hash=createHash('sha256')
100
+ for(const file of ['dist/server.js','dist/observe/system-trace.js','dist/observe/system-trace-router.js','dist/observe/ios-trace/native.js','dist/observe/ios-trace/sessions.js','dist/observe/ios-trace/process.js'])hash.update(await fs.readFile(file))
101
+ checks.status=journeyFailure?'FAIL':'PASS';checks.build_sha256=hash.digest('hex');checks.runtime=process.version;checks.device=deviceId;checks.server=client.getServerVersion()
102
+ checks.physical_device='UNRUN; unsupported initial matrix';checks.native_failure_injection='UNRUN on real target; deterministic unit tests separate'
103
+ checks.input_toolchain=process.env.IDB_COMPANION?'Explicit test companion via IDB_COMPANION; see investigation.md':'Repository default idb companion'
104
+ if(journeyFailure) throw journeyFailure
105
+ }catch(error){checks.status='FAIL';checks.error=String(error);throw error}finally{
106
+ const errors:string[]=[]
107
+ for(const id of owned)try{const r=await call('cancel_system_trace',{traceId:id});if(r.artifact&&!artifacts.includes(r.artifact.path))artifacts.push(r.artifact.path);if(r.cleanup?.status!=='complete')errors.push(id)}catch{errors.push(id)}
108
+ await client.close()
109
+ for(const file of artifacts)await fs.rm(path.dirname(file),{recursive:true,force:true})
110
+ if(installed)command(['simctl','uninstall',deviceId,readinessAppId])
111
+ if(fixture)await fs.rm(fixture.directory,{recursive:true,force:true})
112
+ if(booted)command(['simctl','shutdown',deviceId])
113
+ checks.cleanup={trace_errors:errors,fixture_removed:installed,simulator_shutdown:booted,host_artifacts_removed:true}
114
+ console.log(JSON.stringify(checks))
115
+ if(process.env.IOS_SYSTEM_TRACE_EVIDENCE_PATH)await fs.writeFile(process.env.IOS_SYSTEM_TRACE_EVIDENCE_PATH,JSON.stringify(checks,null,2)+'\n')
116
+ assert.deepEqual(errors,[],'Trace cleanup failed')
117
+ }
118
+ }
119
+ run().catch(error=>{console.error(error);process.exitCode=1})
@@ -1,10 +1,35 @@
1
1
  [
2
2
  {
3
3
  "name": "get_debugger_capabilities",
4
- "description": "Probe optional Android JDK/JDWP prerequisites and matching running debuggable processes; does not attach.",
4
+ "description": "Probe optional platform debugger prerequisites and matching running debuggable processes; does not attach.",
5
5
  "inputSchema": {
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
+ "allOf": [
9
+ {
10
+ "if": {
11
+ "properties": {
12
+ "platform": {
13
+ "const": "ios"
14
+ }
15
+ }
16
+ },
17
+ "then": {
18
+ "properties": {
19
+ "deviceId": {
20
+ "pattern": "^[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$"
21
+ }
22
+ }
23
+ }
24
+ },
25
+ {
26
+ "not": {
27
+ "required": [
28
+ "expectedState"
29
+ ]
30
+ }
31
+ }
32
+ ],
8
33
  "required": [
9
34
  "platform",
10
35
  "deviceId",
@@ -22,7 +47,8 @@
22
47
  "platform": {
23
48
  "type": "string",
24
49
  "enum": [
25
- "android"
50
+ "android",
51
+ "ios"
26
52
  ]
27
53
  },
28
54
  "deviceId": {
@@ -34,7 +60,7 @@
34
60
  "type": "string",
35
61
  "minLength": 1,
36
62
  "maxLength": 512,
37
- "pattern": "^[A-Za-z][\\w]*(?:\\.[A-Za-z][\\w]*)+$"
63
+ "pattern": "^[A-Za-z][\\w-]*(?:\\.[A-Za-z][\\w-]*)+$"
38
64
  },
39
65
  "processId": {
40
66
  "type": "integer",
@@ -47,8 +73,32 @@
47
73
  "maximum": 30000,
48
74
  "default": 10000
49
75
  }
50
- },
76
+ }
77
+ }
78
+ },
79
+ {
80
+ "name": "attach_debugger",
81
+ "description": "Attach an owned Android JDI or iOS simulator LLDB session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.",
82
+ "inputSchema": {
83
+ "type": "object",
84
+ "additionalProperties": false,
51
85
  "allOf": [
86
+ {
87
+ "if": {
88
+ "properties": {
89
+ "platform": {
90
+ "const": "ios"
91
+ }
92
+ }
93
+ },
94
+ "then": {
95
+ "properties": {
96
+ "deviceId": {
97
+ "pattern": "^[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$"
98
+ }
99
+ }
100
+ }
101
+ },
52
102
  {
53
103
  "not": {
54
104
  "required": [
@@ -56,15 +106,7 @@
56
106
  ]
57
107
  }
58
108
  }
59
- ]
60
- }
61
- },
62
- {
63
- "name": "attach_debugger",
64
- "description": "Attach an owned Android JDI session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.",
65
- "inputSchema": {
66
- "type": "object",
67
- "additionalProperties": false,
109
+ ],
68
110
  "required": [
69
111
  "platform",
70
112
  "deviceId",
@@ -82,7 +124,8 @@
82
124
  "platform": {
83
125
  "type": "string",
84
126
  "enum": [
85
- "android"
127
+ "android",
128
+ "ios"
86
129
  ]
87
130
  },
88
131
  "deviceId": {
@@ -94,7 +137,7 @@
94
137
  "type": "string",
95
138
  "minLength": 1,
96
139
  "maxLength": 512,
97
- "pattern": "^[A-Za-z][\\w]*(?:\\.[A-Za-z][\\w]*)+$"
140
+ "pattern": "^[A-Za-z][\\w-]*(?:\\.[A-Za-z][\\w-]*)+$"
98
141
  },
99
142
  "processId": {
100
143
  "type": "integer",
@@ -107,16 +150,7 @@
107
150
  "maximum": 30000,
108
151
  "default": 10000
109
152
  }
110
- },
111
- "allOf": [
112
- {
113
- "not": {
114
- "required": [
115
- "expectedState"
116
- ]
117
- }
118
- }
119
- ]
153
+ }
120
154
  }
121
155
  },
122
156
  {
@@ -162,13 +196,34 @@
162
196
  },
163
197
  {
164
198
  "name": "set_debugger_breakpoint",
165
- "description": "Set an exact binary-class and executable-source-line breakpoint; unloaded classes remain pending. No silent line relocation.",
199
+ "description": "Set an exact source breakpoint: Android className or iOS absolute sourceFile (exactly one), plus line; unloaded classes/images remain pending. No silent line relocation.",
166
200
  "inputSchema": {
167
201
  "type": "object",
168
202
  "additionalProperties": false,
203
+ "oneOf": [
204
+ {
205
+ "required": [
206
+ "className"
207
+ ],
208
+ "not": {
209
+ "required": [
210
+ "sourceFile"
211
+ ]
212
+ }
213
+ },
214
+ {
215
+ "required": [
216
+ "sourceFile"
217
+ ],
218
+ "not": {
219
+ "required": [
220
+ "className"
221
+ ]
222
+ }
223
+ }
224
+ ],
169
225
  "required": [
170
226
  "debugSessionId",
171
- "className",
172
227
  "line"
173
228
  ],
174
229
  "properties": {
@@ -191,6 +246,12 @@
191
246
  "maxLength": 512,
192
247
  "pattern": "^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*$"
193
248
  },
249
+ "sourceFile": {
250
+ "type": "string",
251
+ "minLength": 1,
252
+ "maxLength": 512,
253
+ "pattern": "^\\/[^\\0\\r\\n]+$"
254
+ },
194
255
  "line": {
195
256
  "type": "integer",
196
257
  "minimum": 0,
@@ -372,7 +433,7 @@
372
433
  },
373
434
  {
374
435
  "name": "get_debugger_locals",
375
- "description": "Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters. No evaluation or field traversal.",
436
+ "description": "Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters; iOS aggregates return type summaries. No evaluation or field traversal.",
376
437
  "inputSchema": {
377
438
  "type": "object",
378
439
  "additionalProperties": false,
@@ -534,7 +595,7 @@
534
595
  },
535
596
  {
536
597
  "name": "detach_debugger",
537
- "description": "Dispose the owned debugger connection, release owned suspension and remove its exact forward. Idempotent; retries incomplete cleanup.",
598
+ "description": "Dispose the owned debugger connection, release owned suspension and remove its owned transport. Idempotent; retries incomplete cleanup.",
538
599
  "inputSchema": {
539
600
  "type": "object",
540
601
  "additionalProperties": false,
@@ -575,7 +636,7 @@
575
636
  },
576
637
  {
577
638
  "name": "get_system_trace_capabilities",
578
- "description": "Probe Android Perfetto prerequisites without recording. Device-wide system tracing is optional; iOS is unsupported.",
639
+ "description": "Probe Android Perfetto or iOS Instruments prerequisites without recording. iOS requires explicit booted simulator deviceId; reports template matrix and unknown profiling permissions.",
579
640
  "inputSchema": {
580
641
  "type": "object",
581
642
  "additionalProperties": false,
@@ -593,8 +654,57 @@
593
654
  "deviceId": {
594
655
  "type": "string",
595
656
  "minLength": 1
657
+ },
658
+ "appId": {
659
+ "type": "string",
660
+ "minLength": 1,
661
+ "pattern": "^[a-zA-Z0-9._-]+$",
662
+ "description": "iOS only: running development app bundle identifier; required for iOS start."
663
+ },
664
+ "template": {
665
+ "type": "string",
666
+ "enum": [
667
+ "System Trace",
668
+ "Time Profiler",
669
+ "Animation Hitches",
670
+ "Hangs"
671
+ ],
672
+ "description": "iOS only. Default Time Profiler; initial simulator support is Time Profiler only."
596
673
  }
597
674
  },
675
+ "oneOf": [
676
+ {
677
+ "properties": {
678
+ "platform": {
679
+ "const": "android"
680
+ }
681
+ },
682
+ "not": {
683
+ "anyOf": [
684
+ {
685
+ "required": [
686
+ "appId"
687
+ ]
688
+ },
689
+ {
690
+ "required": [
691
+ "template"
692
+ ]
693
+ }
694
+ ]
695
+ }
696
+ },
697
+ {
698
+ "properties": {
699
+ "platform": {
700
+ "const": "ios"
701
+ }
702
+ },
703
+ "required": [
704
+ "deviceId"
705
+ ]
706
+ }
707
+ ],
598
708
  "allOf": [
599
709
  {
600
710
  "not": {
@@ -608,7 +718,7 @@
608
718
  },
609
719
  {
610
720
  "name": "start_system_trace",
611
- "description": "Start bounded Android Perfetto recording, returning an owned trace_id. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard. Device-wide traces may contain sensitive system annotations; no upload.",
721
+ "description": "Start bounded Android Perfetto or iOS simulator Time Profiler recording, returning an owned trace_id after readiness. iOS requires explicit deviceId and running appId. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard. Traces can contain sensitive system metadata, logs and paths; local user-managed retention, no upload.",
612
722
  "inputSchema": {
613
723
  "type": "object",
614
724
  "additionalProperties": false,
@@ -627,6 +737,22 @@
627
737
  "type": "string",
628
738
  "minLength": 1
629
739
  },
740
+ "appId": {
741
+ "type": "string",
742
+ "minLength": 1,
743
+ "pattern": "^[a-zA-Z0-9._-]+$",
744
+ "description": "iOS only: running development app bundle identifier; required for iOS start."
745
+ },
746
+ "template": {
747
+ "type": "string",
748
+ "enum": [
749
+ "System Trace",
750
+ "Time Profiler",
751
+ "Animation Hitches",
752
+ "Hangs"
753
+ ],
754
+ "description": "iOS only. Default Time Profiler; initial simulator support is Time Profiler only."
755
+ },
630
756
  "durationMs": {
631
757
  "type": "integer",
632
758
  "minimum": 1000,
@@ -634,6 +760,40 @@
634
760
  "default": 30000
635
761
  }
636
762
  },
763
+ "oneOf": [
764
+ {
765
+ "properties": {
766
+ "platform": {
767
+ "const": "android"
768
+ }
769
+ },
770
+ "not": {
771
+ "anyOf": [
772
+ {
773
+ "required": [
774
+ "appId"
775
+ ]
776
+ },
777
+ {
778
+ "required": [
779
+ "template"
780
+ ]
781
+ }
782
+ ]
783
+ }
784
+ },
785
+ {
786
+ "properties": {
787
+ "platform": {
788
+ "const": "ios"
789
+ }
790
+ },
791
+ "required": [
792
+ "deviceId",
793
+ "appId"
794
+ ]
795
+ }
796
+ ],
637
797
  "allOf": [
638
798
  {
639
799
  "not": {
@@ -673,7 +833,7 @@
673
833
  },
674
834
  {
675
835
  "name": "stop_system_trace",
676
- "description": "Stop owned capture gracefully and retrieve a local Perfetto artifact. Retry after a transfer/cleanup failure.",
836
+ "description": "Stop owned capture gracefully and retrieve a local Perfetto file or validated Instruments .trace bundle. Retry after a transfer/cleanup failure.",
677
837
  "inputSchema": {
678
838
  "type": "object",
679
839
  "additionalProperties": false,
@@ -0,0 +1,95 @@
1
+ import UIKit
2
+
3
+ @inline(never) func increment(_ value: Int) -> Int {
4
+ let adjusted = value + 1 // STEP_INCREMENT
5
+ return adjusted // STEP_RETURN
6
+ }
7
+ @inline(never) func helper(_ value: Int) -> Int {
8
+ let result = increment(value) // STEP_HELPER
9
+ return result // HELPER_RETURN
10
+ }
11
+ actor AsyncWork {
12
+ func child(_ input: Int) async -> Int {
13
+ try? await Task.sleep(nanoseconds: 300_000_000)
14
+ let childValue = input + 1
15
+ return childValue // CHILD_RETURN
16
+ }
17
+ @inline(never) func calculate(_ seed: Int) async -> Int {
18
+ let known = seed
19
+ async let childResult = child(known)
20
+ let after = await childResult // BEFORE_AWAIT
21
+ let answer = helper(after + known - seed) // AFTER_AWAIT
22
+ return answer // ASYNC_RETURN
23
+ }
24
+ }
25
+ class Controller: UIViewController {
26
+ let label = UILabel()
27
+ var task: Task<Void, Never>?
28
+ override func viewDidLoad() {
29
+ super.viewDidLoad()
30
+ view.backgroundColor = .white
31
+ label.text = "Debugger ready"
32
+ label.accessibilityIdentifier = "debugger-result"
33
+ let stack = UIStackView()
34
+ stack.axis = .vertical
35
+ stack.spacing = 24
36
+ stack.addArrangedSubview(label)
37
+ for (title, action) in [("Calculate", #selector(calculate)), ("Run async", #selector(runAsync)), ("Start cancellation", #selector(startCancellation)), ("Cancel task", #selector(cancelTask))] {
38
+ let button = UIButton(type: .system)
39
+ button.setTitle(title, for: .normal)
40
+ button.addTarget(self, action: action, for: .touchUpInside)
41
+ stack.addArrangedSubview(button)
42
+ }
43
+ stack.translatesAutoresizingMaskIntoConstraints = false
44
+ view.addSubview(stack)
45
+ NSLayoutConstraint.activate([stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), stack.centerYAnchor.constraint(equalTo: view.centerYAnchor)])
46
+ }
47
+ @objc func calculate() {
48
+ let known = 41
49
+ let result = helper(known) // BREAK_CALCULATE
50
+ label.text = "Debugger result \(result)" // CALCULATE_DONE
51
+ }
52
+ @objc func runAsync() {
53
+ task = Task { @MainActor in
54
+ let result = await AsyncWork().calculate(42)
55
+ label.text = "Async result \(result)"
56
+ }
57
+ }
58
+ @objc func startCancellation() {
59
+ label.text = "Task running"
60
+ task = Task { @MainActor in
61
+ do {
62
+ try await Task.sleep(nanoseconds: 60_000_000_000)
63
+ label.text = "Unexpected completion"
64
+ } catch {
65
+ let cancelled = Task.isCancelled
66
+ label.text = cancelled ? "Task cancelled" : "Unexpected error" // CANCELLED
67
+ }
68
+ }
69
+ }
70
+ @objc func cancelTask() { task?.cancel() }
71
+ }
72
+ @main class App: UIResponder, UIApplicationDelegate {
73
+ var window: UIWindow?
74
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
75
+ window = UIWindow(frame: UIScreen.main.bounds)
76
+ window?.rootViewController = Controller()
77
+ window?.makeKeyAndVisible()
78
+ // Test-only control channel; no HID dependency or debugger evaluation.
79
+ Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
80
+ let commandFile = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("debugger-command")
81
+ guard let command = try? String(contentsOf: commandFile, encoding: .utf8),
82
+ let controller = self?.window?.rootViewController as? Controller else { return }
83
+ try? FileManager.default.removeItem(at: commandFile)
84
+ switch command {
85
+ case "exit": exit(0)
86
+ case "calculate": controller.calculate()
87
+ case "async": controller.runAsync()
88
+ case "start-cancellation": controller.startCancellation()
89
+ case "cancel": controller.cancelTask()
90
+ default: break
91
+ }
92
+ }
93
+ return true
94
+ }
95
+ }
@@ -0,0 +1,27 @@
1
+ # iOS debugger fixture
2
+
3
+ Disposable UIKit app for simulator-only LLDB MCP checks. Builds with Xcode's
4
+ `swiftc -g -Onone`; no external package or user app is used. Select an explicit
5
+ booted simulator:
6
+
7
+ ```sh
8
+ npm run build
9
+ MCP_IOS_DEBUGGER_DEVICE=<UDID> node --import tsx test/device/automated/debugger/runtime.ios.test.ts
10
+ # Include the real 60-second pause expiry:
11
+ MCP_IOS_DEBUGGER_DEVICE=<UDID> MCP_IOS_DEBUGGER_EXTENDED=1 node --import tsx test/device/automated/debugger/runtime.ios.test.ts
12
+ ```
13
+
14
+ Requires macOS, full Xcode with Swift-enabled LLDB/Python, and working idb UI-tree
15
+ reads. The test installs/removes only `dev.mobiledebug.iosfixture`, and cleans its
16
+ private temporary build/module-cache directory. It does not boot/shut down devices.
17
+ The app's fixture-only `Documents/debugger-command` file invokes the same actions
18
+ as its buttons through a main-loop timer. The smoke runner writes only that file
19
+ because the installed idb HID backend expects SimulatorKit at a path absent in
20
+ this Xcode. This is a fixture trigger, not evidence that MCP tap delivery
21
+ succeeded. Paused MCP taps must be rejected before delivery. Completion and
22
+ cancellation use fresh MCP UI-tree assertions.
23
+
24
+ Async work uses a suspended child operation in an actor, known values on either
25
+ side of await, a main-actor UI update and a separate cancellation task. Boundary
26
+ stepping is recorded independently from source-breakpoint coverage. Stop-scoped
27
+ thread/frame IDs do not establish logical task identity.
@@ -0,0 +1,21 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ export const iosDebuggerAppId = 'dev.mobiledebug.iosfixture'
7
+ export const iosDebuggerSource = fileURLToPath(new URL('./Main.swift', import.meta.url))
8
+ export function buildIOSDebuggerFixture() {
9
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'mobile-debug-ios-fixture-'))
10
+ const app = path.join(directory, 'DebuggerFixture.app')
11
+ try {
12
+ execFileSync('/bin/mkdir', ['-p', app])
13
+ const sdk = execFileSync('xcrun', ['--sdk', 'iphonesimulator', '--show-sdk-path'], { encoding: 'utf8' }).trim()
14
+ execFileSync('xcrun', ['swiftc', '-g', '-module-cache-path', path.join(directory, 'modules'), '-Onone', '-parse-as-library', '-sdk', sdk, '-target', `${os.arch() === 'arm64' ? 'arm64' : 'x86_64'}-apple-ios18.0-simulator`, '-module-name', 'DebuggerFixture', iosDebuggerSource, '-o', path.join(app, 'DebuggerFixture')], { timeout: 120000, stdio: 'pipe' })
15
+ writeFileSync(path.join(app, 'Info.plist'), `<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>${iosDebuggerAppId}</string><key>CFBundleExecutable</key><string>DebuggerFixture</string><key>CFBundleName</key><string>DebuggerFixture</string><key>CFBundlePackageType</key><string>APPL</string><key>CFBundleVersion</key><string>1</string><key>CFBundleShortVersionString</key><string>1.0</string><key>LSRequiresIPhoneOS</key><true/><key>UILaunchScreen</key><dict/></dict></plist>`)
16
+ return { app, directory }
17
+ } catch (error) {
18
+ rmSync(directory, { recursive: true, force: true })
19
+ throw error
20
+ }
21
+ }
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict'
2
+ import { promises as fs } from 'node:fs'
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
4
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
5
+
6
+ export async function verifyIosTraceShutdown(target: Record<string,unknown>, env: Record<string,string> = {}) {
7
+ const client=new Client({name:'ios-trace-shutdown',version:'1.0.0'})
8
+ const transport=new StdioClientTransport({command:process.execPath,args:['dist/server.js'],stderr:'pipe',env})
9
+ let diagnostics=''
10
+ transport.stderr?.on('data',chunk=>{diagnostics=(diagnostics+String(chunk)).slice(-4096)})
11
+ try {
12
+ await client.connect(transport)
13
+ const capability=await client.callTool({name:'get_system_trace_capabilities',arguments:target},undefined,{timeout:120000})
14
+ const capabilities=JSON.parse((capability.content as any[]).find(c=>c.type==='text').text)
15
+ assert.equal(capabilities.supported,true,JSON.stringify(capabilities))
16
+ const response=await client.callTool({name:'start_system_trace',arguments:{...target,durationMs:30000}},undefined,{timeout:120000})
17
+ const result=JSON.parse((response.content as any[]).find(c=>c.type==='text').text)
18
+ assert.equal(result.state,'recording',JSON.stringify(result))
19
+ const directory=result.cleanup.remaining_resources[0]
20
+ assert.ok(transport.pid)
21
+ process.kill(transport.pid,'SIGTERM')
22
+ let removed=false
23
+ for(let i=0;i<24;i++){
24
+ try {await fs.stat(directory)} catch(error) {if((error as NodeJS.ErrnoException).code==='ENOENT'){removed=true;break}throw error}
25
+ await new Promise(resolve=>setTimeout(resolve,500))
26
+ }
27
+ assert.equal(removed,true,`Server shutdown must remove its owned partial trace directory; ${diagnostics}`)
28
+ } finally {await client.close()}
29
+ }