mobile-debug-mcp 0.12.4 → 0.12.6

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.
@@ -291,16 +291,117 @@ export class iOSManage {
291
291
  }
292
292
  async startApp(bundleId, deviceId = "booted") {
293
293
  validateBundleId(bundleId);
294
+ // Prepare instrumentation object upfront so it can be returned to callers
295
+ const instrumentation = { ts: new Date().toISOString(), action: 'startApp', cmd: 'xcrun', args: ['simctl', 'launch', deviceId, bundleId], cwd: process.cwd(), env: { PATH: process.env.PATH, XCRUN_PATH: process.env.XCRUN_PATH } };
294
296
  try {
295
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
297
+ // Instrumentation: persist and emit to stderr for server logs
298
+ try {
299
+ await fs.appendFile('/tmp/mcp_startapp_instrument.log', JSON.stringify(instrumentation) + '\n');
300
+ }
301
+ catch (e) { }
302
+ try {
303
+ console.error('MCP-STARTAPP-EXEC', JSON.stringify(instrumentation));
304
+ }
305
+ catch (e) { }
306
+ }
307
+ catch { }
308
+ // Attempt to launch
309
+ let launchResult = null;
310
+ try {
311
+ launchResult = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
312
+ }
313
+ catch (launchErr) {
314
+ // Collect diagnostics when simctl launch fails
315
+ const launchDiag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
296
316
  const device = await getIOSDeviceMetadata(deviceId);
297
- return { device, appStarted: !!result.output, launchTimeMs: 1000 };
317
+ const post = await this.collectPostLaunchDiagnostics(bundleId, deviceId);
318
+ return { device, appStarted: false, launchTimeMs: 0, error: launchErr instanceof Error ? launchErr.message : String(launchErr), diagnostics: { launchDiag, post }, instrumentation };
319
+ }
320
+ // Basic success — but verify RunningBoard/installcoordination didn't mark it as placeholder
321
+ const device = await getIOSDeviceMetadata(deviceId);
322
+ // short wait to let system settle
323
+ await new Promise(r => setTimeout(r, 1000));
324
+ let appinfo = '';
325
+ try {
326
+ const ai = await execCommand(['simctl', 'appinfo', deviceId, bundleId], deviceId);
327
+ appinfo = ai.output || '';
328
+ }
329
+ catch { }
330
+ // capture recent runningboard/installcoordination logs
331
+ const logDiag = execCommandWithDiagnostics(['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--predicate', `(process == "${bundleId}" ) OR eventMessage CONTAINS "installcoordinationd" OR eventMessage CONTAINS "runningboard"`, '--last', '1m'], deviceId);
332
+ const placeholderDetected = (appinfo && /isPlaceholder[:=]?\s*Y/i.test(appinfo)) || (logDiag && ((logDiag.runResult && ((logDiag.runResult.stdout || '').includes('isPlaceholder')) || (logDiag.runResult.stderr || '').includes('isPlaceholder'))));
333
+ if (placeholderDetected) {
334
+ const post = await this.collectPostLaunchDiagnostics(bundleId, deviceId, appinfo);
335
+ return { device, appStarted: false, launchTimeMs: 0, diagnostics: { appinfo, logDiag, post }, instrumentation };
336
+ }
337
+ return { device, appStarted: !!(launchResult && launchResult.output), launchTimeMs: 1000, instrumentation };
338
+ }
339
+ appExecutableName(bundleId) {
340
+ // Best-effort executable name: prefer last component of bundleId
341
+ try {
342
+ const candidate = bundleId.split('.').pop();
343
+ return candidate || bundleId;
344
+ }
345
+ catch {
346
+ return bundleId;
347
+ }
348
+ }
349
+ // Collect bundle- and system-level diagnostics after a failed or placeholder launch
350
+ async collectPostLaunchDiagnostics(bundleId, deviceId = "booted", appinfo) {
351
+ const diagnostics = { ts: new Date().toISOString(), bundleId, deviceId };
352
+ // gather simctl appinfo (if not provided)
353
+ try {
354
+ diagnostics.appinfo = appinfo || ((await execCommand(['simctl', 'appinfo', deviceId, bundleId], deviceId)).output || '');
298
355
  }
299
356
  catch (e) {
300
- const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
301
- const device = await getIOSDeviceMetadata(deviceId);
302
- return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
357
+ diagnostics.appinfoError = String(e);
358
+ }
359
+ // attempt to discover bundle path from appinfo
360
+ let bundlePath = null;
361
+ if (diagnostics.appinfo) {
362
+ const m = diagnostics.appinfo.match(/Path\s*=\s*"?([\S]+)"?/) || diagnostics.appinfo.match(/Container: (\/\S+)/);
363
+ if (m)
364
+ bundlePath = m[1];
365
+ }
366
+ // lipo / file / otool / codesign / xattr
367
+ if (bundlePath) {
368
+ diagnostics.bundlePath = bundlePath;
369
+ const execs = [
370
+ { name: 'file', cmd: ['file', bundlePath + '/' + this.appExecutableName(bundleId)] },
371
+ { name: 'lipo', cmd: ['lipo', '-info', bundlePath + '/' + this.appExecutableName(bundleId)] },
372
+ { name: 'otool-L', cmd: ['otool', '-L', bundlePath + '/' + this.appExecutableName(bundleId)] },
373
+ { name: 'otool-load', cmd: ['otool', '-l', bundlePath + '/' + this.appExecutableName(bundleId)] },
374
+ { name: 'plutil', cmd: ['plutil', '-p', bundlePath + '/Info.plist'] },
375
+ { name: 'codesign', cmd: ['codesign', '-dvvv', bundlePath] },
376
+ { name: 'xattr', cmd: ['xattr', '-l', bundlePath] },
377
+ { name: 'ls', cmd: ['ls', '-la', bundlePath] },
378
+ ];
379
+ diagnostics.bundle = {};
380
+ for (const e of execs) {
381
+ try {
382
+ const r = execCommandWithDiagnostics(e.cmd, deviceId);
383
+ diagnostics.bundle[e.name] = r && r.runResult ? { stdout: r.runResult.stdout, stderr: r.runResult.stderr, code: r.runResult.exitCode } : { error: 'no-result' };
384
+ }
385
+ catch (err) {
386
+ diagnostics.bundle[e.name] = { error: String(err) };
387
+ }
388
+ }
389
+ }
390
+ // collect recent system logs and a screenshot
391
+ try {
392
+ diagnostics.recentLogs = execCommandWithDiagnostics(['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--predicate', `eventMessage CONTAINS "installcoordinationd" OR eventMessage CONTAINS "runningboard"`, '--last', '5m'], deviceId);
393
+ }
394
+ catch (e) {
395
+ diagnostics.recentLogsError = String(e);
396
+ }
397
+ try {
398
+ const shot = await execCommandWithDiagnostics(['simctl', 'io', deviceId, 'screenshot', '--type', 'png', '/tmp/mcp_post_launch_screenshot.png'], deviceId);
399
+ diagnostics.screenshot = { created: true, path: '/tmp/mcp_post_launch_screenshot.png', result: shot && shot.runResult };
400
+ }
401
+ catch (e) {
402
+ diagnostics.screenshotError = String(e);
303
403
  }
404
+ return diagnostics;
304
405
  }
305
406
  async terminateApp(bundleId, deviceId = "booted") {
306
407
  validateBundleId(bundleId);
package/dist/ios/utils.js CHANGED
@@ -96,7 +96,40 @@ export function validateBundleId(bundleId) {
96
96
  }
97
97
  export function execCommand(args, deviceId = "booted") {
98
98
  return new Promise((resolve, reject) => {
99
+ // Instrumentation: append a JSON line with timestamp, command, args, cwd and selected env vars
100
+ try {
101
+ const mcpEnv = {};
102
+ for (const k of Object.keys(process.env || {})) {
103
+ if (k.startsWith('MCP_'))
104
+ mcpEnv[k] = process.env[k];
105
+ }
106
+ const instrument = {
107
+ timestamp: new Date().toISOString(),
108
+ command: getXcrunCmd(),
109
+ args,
110
+ cwd: process.cwd(),
111
+ env: {
112
+ PATH: process.env.PATH,
113
+ XCRUN_PATH: process.env.XCRUN_PATH,
114
+ ...mcpEnv
115
+ }
116
+ };
117
+ try {
118
+ require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(instrument) + '\n');
119
+ }
120
+ catch (e) { }
121
+ }
122
+ catch (e) {
123
+ // swallow instrumentation errors to avoid changing behavior
124
+ }
99
125
  // Use spawn for better stream control and consistency with Android implementation
126
+ // Instrument: emit a JSON line to stderr so the MCP server stderr/stdout capture can record the exact command and env
127
+ try {
128
+ const instLine = JSON.stringify({ ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd(), PATH: process.env.PATH });
129
+ // Use stderr so it appears in server logs reliably
130
+ console.error('MCP-INSTRUMENT-EXEC', instLine);
131
+ }
132
+ catch (e) { }
100
133
  const child = spawn(getXcrunCmd(), args);
101
134
  let stdout = '';
102
135
  let stderr = '';
@@ -110,6 +143,17 @@ export function execCommand(args, deviceId = "booted") {
110
143
  stderr += data.toString();
111
144
  });
112
145
  }
146
+ // Additional instrumentation: write pid and env snapshot when child starts
147
+ try {
148
+ const pidInfo = { ts: new Date().toISOString(), childPid: (child.pid || null), invoked: getXcrunCmd(), args };
149
+ try {
150
+ require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(pidInfo) + '\n');
151
+ }
152
+ catch (e) { }
153
+ }
154
+ catch (e) {
155
+ // ignore
156
+ }
113
157
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
114
158
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
115
159
  const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
@@ -133,6 +177,11 @@ export function execCommand(args, deviceId = "booted") {
133
177
  });
134
178
  }
135
179
  export function execCommandWithDiagnostics(args, deviceId = "booted") {
180
+ try {
181
+ const syncInst = { ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd() };
182
+ require('fs').appendFileSync('/tmp/mcp_exec_instrument_sync.log', JSON.stringify(syncInst) + '\n');
183
+ }
184
+ catch (e) { }
136
185
  // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
137
186
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
138
187
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ios/manage.ts CHANGED
@@ -298,15 +298,102 @@ export class iOSManage {
298
298
 
299
299
  async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
300
300
  validateBundleId(bundleId)
301
+ // Prepare instrumentation object upfront so it can be returned to callers
302
+ const instrumentation = { ts: new Date().toISOString(), action: 'startApp', cmd: 'xcrun', args: ['simctl','launch', deviceId, bundleId], cwd: process.cwd(), env: { PATH: process.env.PATH, XCRUN_PATH: process.env.XCRUN_PATH } }
303
+
301
304
  try {
302
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
303
- const device = await getIOSDeviceMetadata(deviceId)
304
- return { device, appStarted: !!result.output, launchTimeMs: 1000 }
305
- } catch (e:any) {
306
- const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
305
+ // Instrumentation: persist and emit to stderr for server logs
306
+ try { await fs.appendFile('/tmp/mcp_startapp_instrument.log', JSON.stringify(instrumentation) + '\n') } catch (e) {}
307
+ try { console.error('MCP-STARTAPP-EXEC', JSON.stringify(instrumentation)) } catch (e) {}
308
+ } catch {}
309
+
310
+ // Attempt to launch
311
+ let launchResult: any = null
312
+ try {
313
+ launchResult = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
314
+ } catch (launchErr:any) {
315
+ // Collect diagnostics when simctl launch fails
316
+ const launchDiag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
307
317
  const device = await getIOSDeviceMetadata(deviceId)
308
- return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
318
+ const post = await this.collectPostLaunchDiagnostics(bundleId, deviceId)
319
+ return { device, appStarted: false, launchTimeMs: 0, error: launchErr instanceof Error ? launchErr.message : String(launchErr), diagnostics: { launchDiag, post }, instrumentation } as any
320
+ }
321
+
322
+ // Basic success — but verify RunningBoard/installcoordination didn't mark it as placeholder
323
+ const device = await getIOSDeviceMetadata(deviceId)
324
+ // short wait to let system settle
325
+ await new Promise(r => setTimeout(r, 1000))
326
+
327
+ let appinfo = ''
328
+ try {
329
+ const ai = await execCommand(['simctl', 'appinfo', deviceId, bundleId], deviceId)
330
+ appinfo = ai.output || ''
331
+ } catch {}
332
+
333
+ // capture recent runningboard/installcoordination logs
334
+ const logDiag = execCommandWithDiagnostics(['simctl','spawn',deviceId,'log','show','--style','syslog','--predicate',`(process == "${bundleId}" ) OR eventMessage CONTAINS "installcoordinationd" OR eventMessage CONTAINS "runningboard"`, '--last', '1m'], deviceId)
335
+
336
+ const placeholderDetected = (appinfo && /isPlaceholder[:=]?\s*Y/i.test(appinfo)) || (logDiag && ((logDiag.runResult && ((logDiag.runResult.stdout || '').includes('isPlaceholder')) || (logDiag.runResult.stderr || '').includes('isPlaceholder'))))
337
+
338
+ if (placeholderDetected) {
339
+ const post = await this.collectPostLaunchDiagnostics(bundleId, deviceId, appinfo)
340
+ return { device, appStarted: false, launchTimeMs: 0, diagnostics: { appinfo, logDiag, post }, instrumentation } as any
341
+ }
342
+
343
+ return { device, appStarted: !!(launchResult && launchResult.output), launchTimeMs: 1000, instrumentation }
344
+ }
345
+
346
+ appExecutableName(bundleId: string) {
347
+ // Best-effort executable name: prefer last component of bundleId
348
+ try { const candidate = bundleId.split('.').pop(); return candidate || bundleId }
349
+ catch { return bundleId }
350
+ }
351
+
352
+ // Collect bundle- and system-level diagnostics after a failed or placeholder launch
353
+ async collectPostLaunchDiagnostics(bundleId: string, deviceId: string = "booted", appinfo?: string) {
354
+ const diagnostics: any = { ts: new Date().toISOString(), bundleId, deviceId }
355
+
356
+ // gather simctl appinfo (if not provided)
357
+ try { diagnostics.appinfo = appinfo || ((await execCommand(['simctl','appinfo', deviceId, bundleId], deviceId)).output || '') } catch (e) { diagnostics.appinfoError = String(e) }
358
+
359
+ // attempt to discover bundle path from appinfo
360
+ let bundlePath: string | null = null
361
+ if (diagnostics.appinfo) {
362
+ const m = diagnostics.appinfo.match(/Path\s*=\s*"?([\S]+)"?/) || diagnostics.appinfo.match(/Container: (\/\S+)/)
363
+ if (m) bundlePath = m[1]
309
364
  }
365
+
366
+ // lipo / file / otool / codesign / xattr
367
+ if (bundlePath) {
368
+ diagnostics.bundlePath = bundlePath
369
+ const execs = [
370
+ { name: 'file', cmd: ['file', bundlePath + '/' + this.appExecutableName(bundleId)] },
371
+ { name: 'lipo', cmd: ['lipo', '-info', bundlePath + '/' + this.appExecutableName(bundleId)] },
372
+ { name: 'otool-L', cmd: ['otool', '-L', bundlePath + '/' + this.appExecutableName(bundleId)] },
373
+ { name: 'otool-load', cmd: ['otool', '-l', bundlePath + '/' + this.appExecutableName(bundleId)] },
374
+ { name: 'plutil', cmd: ['plutil', '-p', bundlePath + '/Info.plist'] },
375
+ { name: 'codesign', cmd: ['codesign', '-dvvv', bundlePath] },
376
+ { name: 'xattr', cmd: ['xattr', '-l', bundlePath] },
377
+ { name: 'ls', cmd: ['ls', '-la', bundlePath] },
378
+ ]
379
+
380
+ diagnostics.bundle = {}
381
+ for (const e of execs) {
382
+ try {
383
+ const r = execCommandWithDiagnostics(e.cmd, deviceId)
384
+ diagnostics.bundle[e.name] = r && r.runResult ? { stdout: r.runResult.stdout, stderr: r.runResult.stderr, code: r.runResult.exitCode } : { error: 'no-result' }
385
+ } catch (err) { diagnostics.bundle[e.name] = { error: String(err) } }
386
+ }
387
+ }
388
+
389
+ // collect recent system logs and a screenshot
390
+ try { diagnostics.recentLogs = execCommandWithDiagnostics(['simctl','spawn',deviceId,'log','show','--style','syslog','--predicate',`eventMessage CONTAINS "installcoordinationd" OR eventMessage CONTAINS "runningboard"`, '--last', '5m'], deviceId) } catch (e) { diagnostics.recentLogsError = String(e) }
391
+ try {
392
+ const shot = await execCommandWithDiagnostics(['simctl','io', deviceId, 'screenshot', '--type', 'png', '/tmp/mcp_post_launch_screenshot.png'], deviceId)
393
+ diagnostics.screenshot = { created: true, path: '/tmp/mcp_post_launch_screenshot.png', result: shot && shot.runResult }
394
+ } catch (e) { diagnostics.screenshotError = String(e) }
395
+
396
+ return diagnostics
310
397
  }
311
398
 
312
399
  async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
package/src/ios/utils.ts CHANGED
@@ -88,7 +88,41 @@ export function validateBundleId(bundleId: string) {
88
88
 
89
89
  export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
90
90
  return new Promise((resolve, reject) => {
91
+ // Instrumentation: append a JSON line with timestamp, command, args, cwd and selected env vars
92
+ try {
93
+ const mcpEnv: Record<string,string|undefined> = {}
94
+ for (const k of Object.keys(process.env || {})) {
95
+ if (k.startsWith('MCP_')) mcpEnv[k] = process.env[k]
96
+ }
97
+
98
+ const instrument = {
99
+ timestamp: new Date().toISOString(),
100
+ command: getXcrunCmd(),
101
+ args,
102
+ cwd: process.cwd(),
103
+ env: {
104
+ PATH: process.env.PATH,
105
+ XCRUN_PATH: process.env.XCRUN_PATH,
106
+ ...mcpEnv
107
+ }
108
+ }
109
+
110
+ try {
111
+ require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(instrument) + '\n')
112
+ } catch (e) {}
113
+
114
+ } catch (e) {
115
+ // swallow instrumentation errors to avoid changing behavior
116
+ }
117
+
91
118
  // Use spawn for better stream control and consistency with Android implementation
119
+ // Instrument: emit a JSON line to stderr so the MCP server stderr/stdout capture can record the exact command and env
120
+ try {
121
+ const instLine = JSON.stringify({ ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd(), PATH: process.env.PATH })
122
+ // Use stderr so it appears in server logs reliably
123
+ console.error('MCP-INSTRUMENT-EXEC', instLine)
124
+ } catch (e) {}
125
+
92
126
  const child = spawn(getXcrunCmd(), args)
93
127
 
94
128
  let stdout = ''
@@ -106,6 +140,14 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
106
140
  })
107
141
  }
108
142
 
143
+ // Additional instrumentation: write pid and env snapshot when child starts
144
+ try {
145
+ const pidInfo = { ts: new Date().toISOString(), childPid: (child.pid || null), invoked: getXcrunCmd(), args }
146
+ try { require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(pidInfo) + '\n') } catch (e) {}
147
+ } catch (e) {
148
+ // ignore
149
+ }
150
+
109
151
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
110
152
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
111
153
  const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT // choose appropriate timeout
@@ -131,6 +173,11 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
131
173
  }
132
174
 
133
175
  export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
176
+ try {
177
+ const syncInst = { ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd() }
178
+ require('fs').appendFileSync('/tmp/mcp_exec_instrument_sync.log', JSON.stringify(syncInst) + '\n')
179
+ } catch (e) {}
180
+
134
181
  // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
135
182
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
136
183
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
package/src/types.ts CHANGED
@@ -12,6 +12,7 @@ export interface StartAppResponse {
12
12
  launchTimeMs: number;
13
13
  error?: string;
14
14
  diagnostics?: any;
15
+ instrumentation?: any;
15
16
  }
16
17
 
17
18
  export interface TerminateAppResponse {