mobile-debug-mcp 0.4.0 → 0.5.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/src/ios.ts CHANGED
@@ -1,25 +1,243 @@
1
- import { exec } from "child_process"
1
+ import { execFile, spawn } from "child_process"
2
+ import { promises as fs } from "fs"
3
+ import { pathToFileURL } from "url"
4
+ import { StartAppResponse, GetLogsResponse, GetCrashResponse, CaptureIOSScreenshotResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, DeviceInfo } from "./types.js"
2
5
 
3
- export function startIOSApp(bundleId: string): Promise<string> {
6
+ const XCRUN = process.env.XCRUN_PATH || "xcrun"
7
+
8
+ interface IOSResult {
9
+ output: string
10
+ device: DeviceInfo
11
+ }
12
+
13
+ // Validate bundle ID to prevent any potential injection or invalid characters
14
+ function validateBundleId(bundleId: string) {
15
+ if (!bundleId) return
16
+ // Allow alphanumeric, dots, hyphens, and underscores.
17
+ if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
18
+ throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`)
19
+ }
20
+ }
21
+
22
+ function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
4
23
  return new Promise((resolve, reject) => {
5
- exec(
6
- `xcrun simctl launch booted ${bundleId}`,
7
- (err, stdout, stderr) => {
8
- if (err) reject(stderr)
9
- else resolve(stdout)
24
+ // Use spawn for better stream control and consistency with Android implementation
25
+ const child = spawn(XCRUN, args)
26
+
27
+ let stdout = ''
28
+ let stderr = ''
29
+
30
+ if (child.stdout) {
31
+ child.stdout.on('data', (data) => {
32
+ stdout += data.toString()
33
+ })
34
+ }
35
+
36
+ if (child.stderr) {
37
+ child.stderr.on('data', (data) => {
38
+ stderr += data.toString()
39
+ })
40
+ }
41
+
42
+ const timeoutMs = args.includes('log') ? 10000 : 5000 // 10s for logs, 5s for others
43
+ const timeout = setTimeout(() => {
44
+ child.kill()
45
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`))
46
+ }, timeoutMs)
47
+
48
+ child.on('close', (code) => {
49
+ clearTimeout(timeout)
50
+ if (code !== 0) {
51
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`))
52
+ } else {
53
+ resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } as DeviceInfo })
10
54
  }
11
- )
55
+ })
56
+
57
+ child.on('error', (err) => {
58
+ clearTimeout(timeout)
59
+ reject(err)
60
+ })
12
61
  })
13
62
  }
14
63
 
15
- export function getIOSLogs(): Promise<string> {
16
- return new Promise((resolve, reject) => {
17
- exec(
18
- `xcrun simctl spawn booted log show --style syslog --last 1m`,
19
- (err, stdout, stderr) => {
20
- if (err) reject(stderr)
21
- else resolve(stdout)
64
+ function parseRuntimeName(runtime: string): string {
65
+ // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
66
+ try {
67
+ const parts = runtime.split('.')
68
+ const lastPart = parts[parts.length - 1]
69
+ return lastPart.replace(/-/g, ' ').replace('iOS ', 'iOS ') // Keep iOS prefix
70
+ } catch {
71
+ return runtime
72
+ }
73
+ }
74
+
75
+ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
76
+ return new Promise((resolve) => {
77
+ // If deviceId is provided (and not "booted"), we could try to list just that device.
78
+ // But listing all booted devices is usually fine to find the one we want or just one.
79
+ // Let's stick to listing all and filtering if needed, or just return basic info if we can't find it.
80
+ execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
81
+ // Default fallback
82
+ const fallback: DeviceInfo = {
83
+ platform: "ios",
84
+ id: deviceId,
85
+ osVersion: "Unknown",
86
+ model: "Simulator",
87
+ simulator: true,
88
+ }
89
+
90
+ if (err || !stdout) {
91
+ resolve(fallback)
92
+ return
93
+ }
94
+
95
+ try {
96
+ const data = JSON.parse(stdout)
97
+ const devicesMap = data.devices || {}
98
+
99
+ // Find the device
100
+ for (const runtime in devicesMap) {
101
+ const devices = devicesMap[runtime]
102
+ if (Array.isArray(devices)) {
103
+ for (const device of devices) {
104
+ if (deviceId === "booted" || device.udid === deviceId) {
105
+ resolve({
106
+ platform: "ios",
107
+ id: device.udid,
108
+ osVersion: parseRuntimeName(runtime),
109
+ model: device.name,
110
+ simulator: true,
111
+ })
112
+ return
113
+ }
114
+ }
115
+ }
116
+ }
117
+ resolve(fallback)
118
+ } catch (error) {
119
+ resolve(fallback)
22
120
  }
23
- )
121
+ })
24
122
  })
123
+ }
124
+
125
+ export async function startIOSApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
126
+ validateBundleId(bundleId)
127
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
128
+ const device = await getIOSDeviceMetadata(deviceId)
129
+ // Simulate launch time and appStarted for demonstration
130
+ return {
131
+ device,
132
+ appStarted: !!result.output,
133
+ launchTimeMs: 1000,
134
+ }
135
+ }
136
+
137
+ export async function terminateIOSApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
138
+ validateBundleId(bundleId)
139
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
140
+ const device = await getIOSDeviceMetadata(deviceId)
141
+ return {
142
+ device,
143
+ appTerminated: true
144
+ }
145
+ }
146
+
147
+ export async function restartIOSApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
148
+ // terminateIOSApp already validates bundleId
149
+ await terminateIOSApp(bundleId, deviceId)
150
+ const startResult = await startIOSApp(bundleId, deviceId)
151
+ return {
152
+ device: startResult.device,
153
+ appRestarted: startResult.appStarted,
154
+ launchTimeMs: startResult.launchTimeMs
155
+ }
156
+ }
157
+
158
+ export async function resetIOSAppData(bundleId: string, deviceId: string = "booted"): Promise<ResetAppDataResponse> {
159
+ validateBundleId(bundleId)
160
+ await terminateIOSApp(bundleId, deviceId)
161
+ const device = await getIOSDeviceMetadata(deviceId)
162
+
163
+ // Get data container path
164
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
165
+ const dataPath = containerResult.output.trim()
166
+
167
+ if (!dataPath) {
168
+ throw new Error(`Could not find data container for ${bundleId}`)
169
+ }
170
+
171
+ // Clear contents of Library and Documents
172
+ try {
173
+ const libraryPath = `${dataPath}/Library`
174
+ const documentsPath = `${dataPath}/Documents`
175
+ const tmpPath = `${dataPath}/tmp`
176
+
177
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => {})
178
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => {})
179
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
180
+
181
+ // Re-create empty directories as they are expected by apps
182
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => {})
183
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => {})
184
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => {})
185
+
186
+ return {
187
+ device,
188
+ dataCleared: true
189
+ }
190
+ } catch (err) {
191
+ throw new Error(`Failed to clear data for ${bundleId}: ${err instanceof Error ? err.message : String(err)}`)
192
+ }
193
+ }
194
+
195
+ export async function getIOSLogs(appId?: string, deviceId: string = "booted"): Promise<GetLogsResponse> {
196
+ // If appId is provided, use predicate filtering
197
+ // Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
198
+ // but we do need to construct the predicate correctly for log show.
199
+ const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
200
+ if (appId) {
201
+ validateBundleId(appId)
202
+ args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`)
203
+ }
204
+
205
+ const result = await execCommand(args, deviceId)
206
+ const device = await getIOSDeviceMetadata(deviceId)
207
+ const logs = result.output ? result.output.split('\n') : []
208
+ return {
209
+ device,
210
+ logs,
211
+ logCount: logs.length,
212
+ }
213
+ }
214
+
215
+
216
+ export async function captureIOSScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
217
+ const device = await getIOSDeviceMetadata(deviceId)
218
+ const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
219
+
220
+ try {
221
+ // 1. Capture screenshot to temp file
222
+ await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
223
+
224
+ // 2. Read file as base64
225
+ const buffer = await fs.readFile(tmpFile)
226
+ const base64 = buffer.toString('base64')
227
+
228
+ // 3. Clean up
229
+ await fs.rm(tmpFile).catch(() => {})
230
+
231
+ return {
232
+ device,
233
+ screenshot: base64,
234
+ // Default resolution since we can't easily parse it without extra libs
235
+ // Clients will read the real dimensions from the PNG header anyway
236
+ resolution: { width: 0, height: 0 },
237
+ }
238
+ } catch (err) {
239
+ // Ensure cleanup happens even on error
240
+ await fs.rm(tmpFile).catch(() => {})
241
+ throw new Error(`Failed to capture screenshot: ${err instanceof Error ? err.message : String(err)}`)
242
+ }
25
243
  }
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js"
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
4
  import {
@@ -5,13 +6,21 @@ import {
5
6
  CallToolRequestSchema
6
7
  } from "@modelcontextprotocol/sdk/types.js"
7
8
 
8
- import { startAndroidApp, getAndroidLogs } from "./android.js"
9
- import { startIOSApp, getIOSLogs } from "./ios.js"
9
+ import {
10
+ StartAppResponse,
11
+ DeviceInfo,
12
+ TerminateAppResponse,
13
+ RestartAppResponse,
14
+ ResetAppDataResponse
15
+ } from "./types.js"
16
+
17
+ import { startAndroidApp, getAndroidLogs, captureAndroidScreen, getAndroidDeviceMetadata, terminateAndroidApp, restartAndroidApp, resetAndroidAppData } from "./android.js"
18
+ import { startIOSApp, getIOSLogs, captureIOSScreenshot, getIOSDeviceMetadata, terminateIOSApp, restartIOSApp, resetIOSAppData } from "./ios.js"
10
19
 
11
20
  const server = new Server(
12
21
  {
13
22
  name: "mobile-debug-mcp",
14
- version: "0.1.0"
23
+ version: "0.4.0"
15
24
  },
16
25
  {
17
26
  capabilities: {
@@ -20,6 +29,15 @@ const server = new Server(
20
29
  }
21
30
  )
22
31
 
32
+ function wrapResponse<T>(data: T) {
33
+ return {
34
+ content: [{
35
+ type: "text" as const,
36
+ text: JSON.stringify(data, null, 2)
37
+ }]
38
+ }
39
+ }
40
+
23
41
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
24
42
  tools: [
25
43
  {
@@ -32,17 +50,65 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
32
50
  type: "string",
33
51
  enum: ["android", "ios"]
34
52
  },
35
- id: {
53
+ appId: {
36
54
  type: "string",
37
55
  description: "Android package name or iOS bundle id"
56
+ },
57
+ deviceId: {
58
+ type: "string",
59
+ description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
38
60
  }
39
61
  },
40
- required: ["platform", "id"]
62
+ required: ["platform", "appId"]
41
63
  }
42
64
  },
43
65
  {
44
- name: "get_logs",
45
- description: "Get recent logs from Android or iOS simulator",
66
+ name: "terminate_app",
67
+ description: "Terminate a mobile app on Android or iOS simulator",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ platform: {
72
+ type: "string",
73
+ enum: ["android", "ios"]
74
+ },
75
+ appId: {
76
+ type: "string",
77
+ description: "Android package name or iOS bundle id"
78
+ },
79
+ deviceId: {
80
+ type: "string",
81
+ description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
82
+ }
83
+ },
84
+ required: ["platform", "appId"]
85
+ }
86
+ },
87
+ {
88
+ name: "restart_app",
89
+ description: "Restart a mobile app on Android or iOS simulator",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ platform: {
94
+ type: "string",
95
+ enum: ["android", "ios"]
96
+ },
97
+ appId: {
98
+ type: "string",
99
+ description: "Android package name or iOS bundle id"
100
+ },
101
+ deviceId: {
102
+ type: "string",
103
+ description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
104
+ }
105
+ },
106
+ required: ["platform", "appId"]
107
+ }
108
+ },
109
+ {
110
+ name: "reset_app_data",
111
+ description: "Reset app data (clear storage) for a mobile app on Android or iOS simulator",
46
112
  inputSchema: {
47
113
  type: "object",
48
114
  properties: {
@@ -50,16 +116,60 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
50
116
  type: "string",
51
117
  enum: ["android", "ios"]
52
118
  },
53
- id: {
119
+ appId: {
54
120
  type: "string",
55
121
  description: "Android package name or iOS bundle id"
56
122
  },
123
+ deviceId: {
124
+ type: "string",
125
+ description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
126
+ }
127
+ },
128
+ required: ["platform", "appId"]
129
+ }
130
+ },
131
+ {
132
+ name: "get_logs",
133
+ description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ platform: {
138
+ type: "string",
139
+ enum: ["android", "ios"]
140
+ },
141
+ appId: {
142
+ type: "string",
143
+ description: "Filter by Android package name or iOS bundle id"
144
+ },
145
+ deviceId: {
146
+ type: "string",
147
+ description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
148
+ },
57
149
  lines: {
58
150
  type: "number",
59
151
  description: "Number of log lines (android only)"
60
152
  }
61
153
  },
62
- required: ["platform", "id"]
154
+ required: ["platform"]
155
+ }
156
+ },
157
+ {
158
+ name: "capture_screenshot",
159
+ description: "Capture a screenshot from an Android device or iOS simulator. Returns device metadata and the screenshot image.",
160
+ inputSchema: {
161
+ type: "object",
162
+ properties: {
163
+ platform: {
164
+ type: "string",
165
+ enum: ["android", "ios"]
166
+ },
167
+ deviceId: {
168
+ type: "string",
169
+ description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
170
+ }
171
+ },
172
+ required: ["platform"]
63
173
  }
64
174
  }
65
175
  ]
@@ -70,35 +180,206 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
70
180
 
71
181
  try {
72
182
  if (name === "start_app") {
73
- const { platform, id } = args as {
183
+ const { platform, appId, deviceId } = args as {
74
184
  platform: "android" | "ios"
75
- id: string
185
+ appId: string
186
+ deviceId?: string
76
187
  }
77
188
 
78
- const result =
79
- platform === "android"
80
- ? await startAndroidApp(id)
81
- : await startIOSApp(id)
189
+ let appStarted: boolean
190
+ let launchTimeMs: number
191
+ let deviceInfo: DeviceInfo
82
192
 
83
- return {
84
- content: [{ type: "text", text: result }]
193
+ if (platform === "android") {
194
+ const result = await startAndroidApp(appId, deviceId)
195
+ appStarted = result.appStarted
196
+ launchTimeMs = result.launchTimeMs
197
+ deviceInfo = await getAndroidDeviceMetadata(appId, deviceId)
198
+ } else {
199
+ const result = await startIOSApp(appId, deviceId)
200
+ appStarted = result.appStarted
201
+ launchTimeMs = result.launchTimeMs
202
+ deviceInfo = await getIOSDeviceMetadata(deviceId)
203
+ }
204
+
205
+ const response: StartAppResponse = {
206
+ device: deviceInfo,
207
+ appStarted,
208
+ launchTimeMs
209
+ }
210
+
211
+ return wrapResponse(response)
212
+ }
213
+
214
+ if (name === "terminate_app") {
215
+ const { platform, appId, deviceId } = args as {
216
+ platform: "android" | "ios"
217
+ appId: string
218
+ deviceId?: string
219
+ }
220
+
221
+ let appTerminated: boolean
222
+ let deviceInfo: DeviceInfo
223
+
224
+ if (platform === "android") {
225
+ const result = await terminateAndroidApp(appId, deviceId)
226
+ appTerminated = result.appTerminated
227
+ deviceInfo = await getAndroidDeviceMetadata(appId, deviceId)
228
+ } else {
229
+ const result = await terminateIOSApp(appId, deviceId)
230
+ appTerminated = result.appTerminated
231
+ deviceInfo = await getIOSDeviceMetadata(deviceId)
232
+ }
233
+
234
+ const response: TerminateAppResponse = {
235
+ device: deviceInfo,
236
+ appTerminated
237
+ }
238
+
239
+ return wrapResponse(response)
240
+ }
241
+
242
+ if (name === "restart_app") {
243
+ const { platform, appId, deviceId } = args as {
244
+ platform: "android" | "ios"
245
+ appId: string
246
+ deviceId?: string
247
+ }
248
+
249
+ let appRestarted: boolean
250
+ let launchTimeMs: number
251
+ let deviceInfo: DeviceInfo
252
+
253
+ if (platform === "android") {
254
+ const result = await restartAndroidApp(appId, deviceId)
255
+ appRestarted = result.appRestarted
256
+ launchTimeMs = result.launchTimeMs
257
+ deviceInfo = await getAndroidDeviceMetadata(appId, deviceId)
258
+ } else {
259
+ const result = await restartIOSApp(appId, deviceId)
260
+ appRestarted = result.appRestarted
261
+ launchTimeMs = result.launchTimeMs
262
+ deviceInfo = await getIOSDeviceMetadata(deviceId)
263
+ }
264
+
265
+ const response: RestartAppResponse = {
266
+ device: deviceInfo,
267
+ appRestarted,
268
+ launchTimeMs
85
269
  }
270
+
271
+ return wrapResponse(response)
272
+ }
273
+
274
+ if (name === "reset_app_data") {
275
+ const { platform, appId, deviceId } = args as {
276
+ platform: "android" | "ios"
277
+ appId: string
278
+ deviceId?: string
279
+ }
280
+
281
+ let dataCleared: boolean
282
+ let deviceInfo: DeviceInfo
283
+
284
+ if (platform === "android") {
285
+ const result = await resetAndroidAppData(appId, deviceId)
286
+ dataCleared = result.dataCleared
287
+ deviceInfo = await getAndroidDeviceMetadata(appId, deviceId)
288
+ } else {
289
+ const result = await resetIOSAppData(appId, deviceId)
290
+ dataCleared = result.dataCleared
291
+ deviceInfo = await getIOSDeviceMetadata(deviceId)
292
+ }
293
+
294
+ const response: ResetAppDataResponse = {
295
+ device: deviceInfo,
296
+ dataCleared
297
+ }
298
+
299
+ return wrapResponse(response)
86
300
  }
87
301
 
88
302
  if (name === "get_logs") {
89
- const { platform, id, lines } = args as {
303
+ const { platform, appId, deviceId, lines } = args as {
90
304
  platform: "android" | "ios"
91
- id: string
305
+ appId?: string
306
+ deviceId?: string
92
307
  lines?: number
93
308
  }
94
309
 
95
- const logs =
96
- platform === "android"
97
- ? await getAndroidLogs(id, lines ?? 200)
98
- : await getIOSLogs()
310
+ let logs: string[]
311
+ let deviceInfo: DeviceInfo
99
312
 
313
+ if (platform === "android") {
314
+ deviceInfo = await getAndroidDeviceMetadata(appId || "", deviceId)
315
+ const response = await getAndroidLogs(appId, lines ?? 200, deviceId)
316
+ logs = Array.isArray(response.logs) ? response.logs : []
317
+ } else {
318
+ deviceInfo = await getIOSDeviceMetadata(deviceId)
319
+ const response = await getIOSLogs(appId, deviceId)
320
+ logs = Array.isArray(response.logs) ? response.logs : []
321
+ }
322
+
323
+ // Filter crash lines (e.g. lines containing 'FATAL EXCEPTION') for internal or AI use
324
+ const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'))
325
+
326
+ // Return device metadata plus logs
100
327
  return {
101
- content: [{ type: "text", text: logs }]
328
+ content: [
329
+ {
330
+ type: "text",
331
+ text: JSON.stringify({
332
+ device: deviceInfo,
333
+ result: {
334
+ lines: logs.length,
335
+ crashLines: crashLines.length > 0 ? crashLines : undefined
336
+ }
337
+ }, null, 2)
338
+ },
339
+ {
340
+ type: "text",
341
+ text: logs.join("\n")
342
+ }
343
+ ]
344
+ }
345
+ }
346
+
347
+ if (name === "capture_screenshot") {
348
+ const { platform, deviceId } = args as { platform: "android" | "ios"; deviceId?: string }
349
+
350
+ let screenshot: string
351
+ let resolution: { width: number; height: number }
352
+ let deviceInfo: DeviceInfo
353
+
354
+ if (platform === "android") {
355
+ deviceInfo = await getAndroidDeviceMetadata("", deviceId)
356
+ const result = await captureAndroidScreen(deviceId)
357
+ screenshot = result.screenshot
358
+ resolution = result.resolution
359
+ } else {
360
+ deviceInfo = await getIOSDeviceMetadata(deviceId)
361
+ const result = await captureIOSScreenshot(deviceId)
362
+ screenshot = result.screenshot
363
+ resolution = result.resolution
364
+ }
365
+
366
+ return {
367
+ content: [
368
+ {
369
+ type: "text",
370
+ text: JSON.stringify({
371
+ device: deviceInfo,
372
+ result: {
373
+ resolution
374
+ }
375
+ }, null, 2)
376
+ },
377
+ {
378
+ type: "image",
379
+ data: screenshot,
380
+ mimeType: "image/png"
381
+ }
382
+ ]
102
383
  }
103
384
  }
104
385
  } catch (error) {