mobile-debug-mcp 0.10.0 → 0.12.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 (67) hide show
  1. package/README.md +20 -5
  2. package/dist/android/diagnostics.js +24 -0
  3. package/dist/android/interact.js +1 -145
  4. package/dist/android/manage.js +162 -0
  5. package/dist/android/observe.js +133 -88
  6. package/dist/android/run.js +187 -0
  7. package/dist/android/utils.js +137 -147
  8. package/dist/ios/interact.js +4 -175
  9. package/dist/ios/manage.js +169 -0
  10. package/dist/ios/observe.js +129 -13
  11. package/dist/ios/run.js +200 -0
  12. package/dist/ios/utils.js +138 -124
  13. package/dist/server.js +45 -17
  14. package/dist/tools/interact.js +21 -71
  15. package/dist/tools/manage.js +180 -0
  16. package/dist/tools/observe.js +23 -69
  17. package/dist/tools/run.js +180 -0
  18. package/dist/utils/diagnostics.js +25 -0
  19. package/docs/CHANGELOG.md +14 -0
  20. package/eslint.config.js +22 -1
  21. package/package.json +8 -5
  22. package/scripts/check-idb.js +83 -0
  23. package/scripts/check-idb.ts +73 -0
  24. package/scripts/idb-helper.ts +76 -0
  25. package/scripts/install-idb.js +88 -0
  26. package/scripts/install-idb.ts +90 -0
  27. package/scripts/run-ios-smoke.ts +34 -0
  28. package/scripts/run-ios-ui-tree-tap.ts +33 -0
  29. package/src/android/diagnostics.ts +23 -0
  30. package/src/android/interact.ts +2 -155
  31. package/src/android/manage.ts +157 -0
  32. package/src/android/observe.ts +129 -97
  33. package/src/android/utils.ts +147 -149
  34. package/src/ios/interact.ts +5 -181
  35. package/src/ios/manage.ts +164 -0
  36. package/src/ios/observe.ts +130 -14
  37. package/src/ios/utils.ts +127 -128
  38. package/src/server.ts +47 -17
  39. package/src/tools/interact.ts +23 -62
  40. package/src/tools/manage.ts +171 -0
  41. package/src/tools/observe.ts +24 -74
  42. package/src/types.ts +9 -0
  43. package/src/utils/diagnostics.ts +36 -0
  44. package/test/device/README.md +49 -0
  45. package/test/device/index.ts +27 -0
  46. package/test/device/manage/run-build-install-ios.ts +82 -0
  47. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  48. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  49. package/test/{integration → device/observe}/logstream-real.ts +5 -4
  50. package/test/{integration → device/utils}/test-dist.ts +2 -2
  51. package/test/unit/index.ts +10 -6
  52. package/test/unit/manage/build.test.ts +83 -0
  53. package/test/unit/manage/build_and_install.test.ts +134 -0
  54. package/test/unit/manage/diagnostics.test.ts +85 -0
  55. package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
  56. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  57. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
  58. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  59. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  60. package/tsconfig.json +2 -1
  61. package/test/integration/index.ts +0 -8
  62. package/test/integration/test-dist.mjs +0 -41
  63. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  64. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  65. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  66. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  67. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
@@ -0,0 +1,200 @@
1
+ import { promises as fs } from "fs";
2
+ import { spawn } from "child_process";
3
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js";
4
+ import path from "path";
5
+ export class iOSManage {
6
+ async build(projectPath, _variant) {
7
+ void _variant;
8
+ try {
9
+ async function findAppBundle(dir) {
10
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
11
+ for (const e of entries) {
12
+ const full = path.join(dir, e.name);
13
+ if (e.isDirectory()) {
14
+ if (full.endsWith('.app'))
15
+ return full;
16
+ const found = await findAppBundle(full);
17
+ if (found)
18
+ return found;
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+ const files = await fs.readdir(projectPath).catch(() => []);
24
+ const workspace = files.find(f => f.endsWith('.xcworkspace'));
25
+ const proj = files.find(f => f.endsWith('.xcodeproj'));
26
+ if (!workspace && !proj)
27
+ return { error: 'No Xcode project or workspace found' };
28
+ let buildArgs;
29
+ if (workspace) {
30
+ const workspacePath = path.join(projectPath, workspace);
31
+ const scheme = workspace.replace(/\.xcworkspace$/, '');
32
+ buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
33
+ }
34
+ else {
35
+ const projectPathFull = path.join(projectPath, proj);
36
+ const scheme = proj.replace(/\.xcodeproj$/, '');
37
+ buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
38
+ }
39
+ await new Promise((resolve, reject) => {
40
+ const proc = spawn('xcodebuild', buildArgs, { cwd: projectPath });
41
+ let stderr = '';
42
+ proc.stderr?.on('data', d => stderr += d.toString());
43
+ proc.on('close', code => {
44
+ if (code === 0)
45
+ resolve();
46
+ else
47
+ reject(new Error(stderr || `xcodebuild failed with code ${code}`));
48
+ });
49
+ proc.on('error', err => reject(err));
50
+ });
51
+ const built = await findAppBundle(projectPath);
52
+ if (!built)
53
+ return { error: 'Could not find .app after build' };
54
+ return { artifactPath: built };
55
+ }
56
+ catch (e) {
57
+ return { error: e instanceof Error ? e.message : String(e) };
58
+ }
59
+ }
60
+ async installApp(appPath, deviceId = "booted") {
61
+ const device = await getIOSDeviceMetadata(deviceId);
62
+ async function findAppBundle(dir) {
63
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
64
+ for (const e of entries) {
65
+ const full = path.join(dir, e.name);
66
+ if (e.isDirectory()) {
67
+ if (full.endsWith('.app'))
68
+ return full;
69
+ const found = await findAppBundle(full);
70
+ if (found)
71
+ return found;
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+ try {
77
+ let toInstall = appPath;
78
+ const stat = await fs.stat(appPath).catch(() => null);
79
+ if (stat && stat.isDirectory()) {
80
+ if (appPath.endsWith('.app')) {
81
+ toInstall = appPath;
82
+ }
83
+ else {
84
+ const found = await findAppBundle(appPath);
85
+ if (found) {
86
+ toInstall = found;
87
+ }
88
+ else {
89
+ const files = await fs.readdir(appPath).catch(() => []);
90
+ const workspace = files.find(f => f.endsWith('.xcworkspace'));
91
+ const proj = files.find(f => f.endsWith('.xcodeproj'));
92
+ if (!workspace && !proj)
93
+ throw new Error('No .app bundle, .xcworkspace or .xcodeproj found in directory');
94
+ let buildArgs;
95
+ if (workspace) {
96
+ const workspacePath = path.join(appPath, workspace);
97
+ const scheme = workspace.replace(/\.xcworkspace$/, '');
98
+ buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet'];
99
+ }
100
+ else {
101
+ const projectPath = path.join(appPath, proj);
102
+ const scheme = proj.replace(/\.xcodeproj$/, '');
103
+ buildArgs = ['-project', projectPath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet'];
104
+ }
105
+ await new Promise((resolve, reject) => {
106
+ const proc = spawn('xcodebuild', buildArgs, { cwd: appPath });
107
+ let stderr = '';
108
+ proc.stderr?.on('data', d => stderr += d.toString());
109
+ proc.on('close', code => {
110
+ if (code === 0)
111
+ resolve();
112
+ else
113
+ reject(new Error(stderr || `xcodebuild failed with code ${code}`));
114
+ });
115
+ proc.on('error', err => reject(err));
116
+ });
117
+ const built = await findAppBundle(appPath);
118
+ if (!built)
119
+ throw new Error('Could not locate built .app after xcodebuild');
120
+ toInstall = built;
121
+ }
122
+ }
123
+ }
124
+ try {
125
+ const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId);
126
+ return { device, installed: true, output: res.output };
127
+ }
128
+ catch (e) {
129
+ try {
130
+ const child = spawn(IDB, ['--version']);
131
+ const idbExists = await new Promise((resolve) => {
132
+ child.on('error', () => resolve(false));
133
+ child.on('close', (code) => resolve(code === 0));
134
+ });
135
+ if (idbExists) {
136
+ await new Promise((resolve, reject) => {
137
+ const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
138
+ let stderr = '';
139
+ proc.stderr.on('data', d => stderr += d.toString());
140
+ proc.on('close', code => {
141
+ if (code === 0)
142
+ resolve();
143
+ else
144
+ reject(new Error(stderr || `idb install failed with code ${code}`));
145
+ });
146
+ proc.on('error', err => reject(err));
147
+ });
148
+ return { device, installed: true };
149
+ }
150
+ }
151
+ catch { }
152
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
153
+ }
154
+ }
155
+ catch (e) {
156
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
157
+ }
158
+ }
159
+ async startApp(bundleId, deviceId = "booted") {
160
+ validateBundleId(bundleId);
161
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
162
+ const device = await getIOSDeviceMetadata(deviceId);
163
+ return { device, appStarted: !!result.output, launchTimeMs: 1000 };
164
+ }
165
+ async terminateApp(bundleId, deviceId = "booted") {
166
+ validateBundleId(bundleId);
167
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
168
+ const device = await getIOSDeviceMetadata(deviceId);
169
+ return { device, appTerminated: true };
170
+ }
171
+ async restartApp(bundleId, deviceId = "booted") {
172
+ await this.terminateApp(bundleId, deviceId);
173
+ const startResult = await this.startApp(bundleId, deviceId);
174
+ return { device: startResult.device, appRestarted: startResult.appStarted, launchTimeMs: startResult.launchTimeMs };
175
+ }
176
+ async resetAppData(bundleId, deviceId = "booted") {
177
+ validateBundleId(bundleId);
178
+ await this.terminateApp(bundleId, deviceId);
179
+ const device = await getIOSDeviceMetadata(deviceId);
180
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
181
+ const dataPath = containerResult.output.trim();
182
+ if (!dataPath)
183
+ throw new Error(`Could not find data container for ${bundleId}`);
184
+ try {
185
+ const libraryPath = `${dataPath}/Library`;
186
+ const documentsPath = `${dataPath}/Documents`;
187
+ const tmpPath = `${dataPath}/tmp`;
188
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
189
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
190
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
191
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
192
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
193
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
194
+ return { device, dataCleared: true };
195
+ }
196
+ catch (e) {
197
+ throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
198
+ }
199
+ }
200
+ }
package/dist/ios/utils.js CHANGED
@@ -1,6 +1,90 @@
1
- import { execFile, spawn } from "child_process";
2
- export const XCRUN = process.env.XCRUN_PATH || "xcrun";
3
- export const IDB = "idb";
1
+ import { execFile, spawn, execSync, spawnSync } from "child_process";
2
+ import { promises as fsPromises } from 'fs';
3
+ import path from 'path';
4
+ import { makeEnvSnapshot } from '../utils/diagnostics.js';
5
+ export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun'; }
6
+ export function getConfiguredIdbPath() {
7
+ if (process.env.MCP_IDB_PATH)
8
+ return process.env.MCP_IDB_PATH;
9
+ if (process.env.IDB_PATH)
10
+ return process.env.IDB_PATH;
11
+ const cfgPaths = [
12
+ process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
13
+ `${process.cwd()}/mcp.config.json`
14
+ ];
15
+ try {
16
+ const fs = require('fs');
17
+ for (const p of cfgPaths) {
18
+ if (!p)
19
+ continue;
20
+ try {
21
+ if (fs.existsSync(p)) {
22
+ const raw = fs.readFileSync(p, 'utf8');
23
+ const json = JSON.parse(raw);
24
+ if (json) {
25
+ if (json.idbPath)
26
+ return json.idbPath;
27
+ if (json.IDB_PATH)
28
+ return json.IDB_PATH;
29
+ }
30
+ }
31
+ }
32
+ catch { }
33
+ }
34
+ }
35
+ catch { }
36
+ return undefined;
37
+ }
38
+ export function getIdbCmd() {
39
+ const cfg = getConfiguredIdbPath();
40
+ if (cfg)
41
+ return cfg;
42
+ if (process.env.IDB_PATH)
43
+ return process.env.IDB_PATH;
44
+ try {
45
+ const p = execSync('which idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
46
+ if (p)
47
+ return p;
48
+ }
49
+ catch { }
50
+ try {
51
+ const p2 = execSync('command -v idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
52
+ if (p2)
53
+ return p2;
54
+ }
55
+ catch { }
56
+ // check common user locations
57
+ const common = [
58
+ `${process.env.HOME}/Library/Python/3.9/bin/idb`,
59
+ `${process.env.HOME}/Library/Python/3.10/bin/idb`,
60
+ '/opt/homebrew/bin/idb',
61
+ '/usr/local/bin/idb',
62
+ ];
63
+ for (const c of common) {
64
+ try {
65
+ execSync(`test -x ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] });
66
+ return c;
67
+ }
68
+ catch { }
69
+ }
70
+ return 'idb';
71
+ }
72
+ export async function isIDBInstalled() {
73
+ const cmd = getIdbCmd();
74
+ try {
75
+ execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] });
76
+ return true;
77
+ }
78
+ catch {
79
+ try {
80
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ }
4
88
  // Validate bundle ID to prevent any potential injection or invalid characters
5
89
  export function validateBundleId(bundleId) {
6
90
  if (!bundleId)
@@ -13,7 +97,7 @@ export function validateBundleId(bundleId) {
13
97
  export function execCommand(args, deviceId = "booted") {
14
98
  return new Promise((resolve, reject) => {
15
99
  // Use spawn for better stream control and consistency with Android implementation
16
- const child = spawn(XCRUN, args);
100
+ const child = spawn(getXcrunCmd(), args);
17
101
  let stdout = '';
18
102
  let stderr = '';
19
103
  if (child.stdout) {
@@ -26,10 +110,12 @@ export function execCommand(args, deviceId = "booted") {
26
110
  stderr += data.toString();
27
111
  });
28
112
  }
29
- const timeoutMs = args.includes('log') ? 10000 : 5000; // 10s for logs, 5s for others
113
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
114
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
115
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
30
116
  const timeout = setTimeout(() => {
31
117
  child.kill();
32
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`));
118
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
33
119
  }, timeoutMs);
34
120
  child.on('close', (code) => {
35
121
  clearTimeout(timeout);
@@ -46,6 +132,35 @@ export function execCommand(args, deviceId = "booted") {
46
132
  });
47
133
  });
48
134
  }
135
+ export function execCommandWithDiagnostics(args, deviceId = "booted") {
136
+ // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
137
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
138
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
139
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT;
140
+ const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs });
141
+ const runResult = {
142
+ exitCode: typeof res.status === 'number' ? res.status : null,
143
+ stdout: res.stdout || '',
144
+ stderr: res.stderr || '',
145
+ envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
146
+ command: getXcrunCmd(),
147
+ args,
148
+ deviceId
149
+ };
150
+ if (res.status !== 0) {
151
+ // include suggested fixes for common errors
152
+ const suggested = [];
153
+ if ((runResult.stderr || '').includes('xcodebuild: error')) {
154
+ suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.');
155
+ }
156
+ if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
157
+ suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.');
158
+ }
159
+ // Return diagnostics object
160
+ return { runResult: { ...runResult, suggestedFixes: suggested } };
161
+ }
162
+ return { runResult: { ...runResult, suggestedFixes: [] } };
163
+ }
49
164
  function parseRuntimeName(runtime) {
50
165
  // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
51
166
  try {
@@ -65,13 +180,24 @@ function parseRuntimeName(runtime) {
65
180
  return runtime;
66
181
  }
67
182
  }
183
+ export async function findAppBundle(dir) {
184
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => []);
185
+ for (const e of entries) {
186
+ const full = path.join(dir, e.name);
187
+ if (e.isDirectory()) {
188
+ if (full.endsWith('.app'))
189
+ return full;
190
+ const found = await findAppBundle(full);
191
+ if (found)
192
+ return found;
193
+ }
194
+ }
195
+ return undefined;
196
+ }
68
197
  export async function getIOSDeviceMetadata(deviceId = "booted") {
69
198
  return new Promise((resolve) => {
70
- // If deviceId is provided (and not "booted"), we could try to list just that device.
71
- // But listing all booted devices is usually fine to find the one we want or just one.
72
- // Let's stick to listing all and filtering if needed, or just return basic info if we can't find it.
73
- execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
74
- // Default fallback
199
+ // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
200
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
75
201
  const fallback = {
76
202
  platform: "ios",
77
203
  id: deviceId,
@@ -86,7 +212,6 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
86
212
  try {
87
213
  const data = JSON.parse(stdout);
88
214
  const devicesMap = data.devices || {};
89
- // Find the device
90
215
  for (const runtime in devicesMap) {
91
216
  const devices = devicesMap[runtime];
92
217
  if (Array.isArray(devices)) {
@@ -114,7 +239,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
114
239
  }
115
240
  export async function listIOSDevices(appId) {
116
241
  return new Promise((resolve) => {
117
- execFile(XCRUN, ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
242
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
118
243
  if (err || !stdout)
119
244
  return resolve([]);
120
245
  try {
@@ -155,114 +280,3 @@ export async function listIOSDevices(appId) {
155
280
  });
156
281
  });
157
282
  }
158
- // --- iOS live log stream support ---
159
- import { createWriteStream, promises as fsPromises } from 'fs';
160
- import path from 'path';
161
- import { parseLogLine } from '../android/utils.js';
162
- const iosActiveLogStreams = new Map();
163
- // Test helpers
164
- export function _setIOSActiveLogStream(sessionId, file) {
165
- iosActiveLogStreams.set(sessionId, { proc: {}, file });
166
- }
167
- export function _clearIOSActiveLogStream(sessionId) {
168
- iosActiveLogStreams.delete(sessionId);
169
- }
170
- export async function startIOSLogStream(bundleId, deviceId = 'booted', sessionId = 'default') {
171
- try {
172
- // Build predicate to filter by process or subsystem
173
- const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`;
174
- // Prevent multiple streams per session
175
- if (iosActiveLogStreams.has(sessionId)) {
176
- try {
177
- iosActiveLogStreams.get(sessionId).proc.kill();
178
- }
179
- catch { }
180
- iosActiveLogStreams.delete(sessionId);
181
- }
182
- // Start simctl log stream: xcrun simctl spawn <device> log stream --style syslog --predicate '<predicate>'
183
- const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
184
- const proc = spawn(XCRUN, args);
185
- // Prepare output file
186
- const tmpDir = process.env.TMPDIR || '/tmp';
187
- const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
188
- const stream = createWriteStream(file, { flags: 'a' });
189
- proc.stdout.on('data', (chunk) => {
190
- const text = chunk.toString();
191
- const lines = text.split(/\r?\n/).filter(Boolean);
192
- for (const l of lines) {
193
- // Try to parse with shared parser; parser may be optimized for Android but extracts exceptions and message
194
- const entry = parseLogLine(l);
195
- stream.write(JSON.stringify(entry) + '\n');
196
- }
197
- });
198
- proc.stderr.on('data', (chunk) => {
199
- const text = chunk.toString();
200
- const lines = text.split(/\r?\n/).filter(Boolean);
201
- for (const l of lines) {
202
- const entry = { timestamp: '', level: 'E', tag: 'xcrun', message: l };
203
- stream.write(JSON.stringify(entry) + '\n');
204
- }
205
- });
206
- proc.on('close', () => {
207
- stream.end();
208
- iosActiveLogStreams.delete(sessionId);
209
- });
210
- iosActiveLogStreams.set(sessionId, { proc, file });
211
- return { success: true, stream_started: true };
212
- }
213
- catch {
214
- return { success: false, error: 'log_stream_start_failed' };
215
- }
216
- }
217
- export async function stopIOSLogStream(sessionId = 'default') {
218
- const entry = iosActiveLogStreams.get(sessionId);
219
- if (!entry)
220
- return { success: true };
221
- try {
222
- entry.proc.kill();
223
- }
224
- catch { }
225
- iosActiveLogStreams.delete(sessionId);
226
- return { success: true };
227
- }
228
- export async function readIOSLogStreamLines(sessionId = 'default', limit = 100, since) {
229
- const entry = iosActiveLogStreams.get(sessionId);
230
- if (!entry)
231
- return { entries: [] };
232
- try {
233
- const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
234
- if (!data)
235
- return { entries: [], crash_summary: { crash_detected: false } };
236
- const lines = data.split(/\r?\n/).filter(Boolean);
237
- const parsed = lines.map(l => {
238
- try {
239
- return JSON.parse(l);
240
- }
241
- catch {
242
- return { message: l, _iso: null, crash: false };
243
- }
244
- });
245
- // Minimal since filtering if provided
246
- let filtered = parsed;
247
- if (since) {
248
- let sinceMs = null;
249
- if (/^\d+$/.test(since))
250
- sinceMs = Number(since);
251
- else {
252
- const sDate = new Date(since);
253
- if (!isNaN(sDate.getTime()))
254
- sinceMs = sDate.getTime();
255
- }
256
- if (sinceMs !== null) {
257
- filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
258
- }
259
- }
260
- const entries = filtered.slice(-Math.max(0, limit));
261
- const crashEntry = entries.find(e => e.crash);
262
- const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
263
- return { entries, crash_summary };
264
- }
265
- catch {
266
- return { entries: [], crash_summary: { crash_detected: false } };
267
- }
268
- }
package/dist/server.js CHANGED
@@ -2,12 +2,11 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { ToolsManage } from './tools/manage.js';
5
6
  import { ToolsInteract } from './tools/interact.js';
6
7
  import { ToolsObserve } from './tools/observe.js';
7
- import { AndroidInteract } from './android/interact.js';
8
- import { iOSInteract } from './ios/interact.js';
9
- import { AndroidObserve } from './android/observe.js';
10
- import { iOSObserve } from './ios/observe.js';
8
+ import { AndroidManage } from './android/manage.js';
9
+ import { iOSManage } from './ios/manage.js';
11
10
  const server = new Server({
12
11
  name: "mobile-debug-mcp",
13
12
  version: "0.7.0"
@@ -127,6 +126,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
127
126
  required: ["appPath"]
128
127
  }
129
128
  },
129
+ {
130
+ name: "build_app",
131
+ description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
136
+ projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
137
+ variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
138
+ },
139
+ required: ["projectPath"]
140
+ }
141
+ },
130
142
  {
131
143
  name: "get_logs",
132
144
  description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
@@ -375,7 +387,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
375
387
  try {
376
388
  if (name === "start_app") {
377
389
  const { platform, appId, deviceId } = args;
378
- const res = await (platform === 'android' ? new AndroidInteract().startApp(appId, deviceId) : new iOSInteract().startApp(appId, deviceId));
390
+ const res = await (platform === 'android' ? new AndroidManage().startApp(appId, deviceId) : new iOSManage().startApp(appId, deviceId));
379
391
  const response = {
380
392
  device: res.device,
381
393
  appStarted: res.appStarted,
@@ -385,25 +397,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
385
397
  }
386
398
  if (name === "terminate_app") {
387
399
  const { platform, appId, deviceId } = args;
388
- const res = await (platform === 'android' ? new AndroidInteract().terminateApp(appId, deviceId) : new iOSInteract().terminateApp(appId, deviceId));
400
+ const res = await (platform === 'android' ? new AndroidManage().terminateApp(appId, deviceId) : new iOSManage().terminateApp(appId, deviceId));
389
401
  const response = { device: res.device, appTerminated: res.appTerminated };
390
402
  return wrapResponse(response);
391
403
  }
392
404
  if (name === "restart_app") {
393
405
  const { platform, appId, deviceId } = args;
394
- const res = await (platform === 'android' ? new AndroidInteract().restartApp(appId, deviceId) : new iOSInteract().restartApp(appId, deviceId));
406
+ const res = await (platform === 'android' ? new AndroidManage().restartApp(appId, deviceId) : new iOSManage().restartApp(appId, deviceId));
395
407
  const response = { device: res.device, appRestarted: res.appRestarted, launchTimeMs: res.launchTimeMs };
396
408
  return wrapResponse(response);
397
409
  }
398
410
  if (name === "reset_app_data") {
399
411
  const { platform, appId, deviceId } = args;
400
- const res = await (platform === 'android' ? new AndroidInteract().resetAppData(appId, deviceId) : new iOSInteract().resetAppData(appId, deviceId));
412
+ const res = await (platform === 'android' ? new AndroidManage().resetAppData(appId, deviceId) : new iOSManage().resetAppData(appId, deviceId));
401
413
  const response = { device: res.device, dataCleared: res.dataCleared };
402
414
  return wrapResponse(response);
403
415
  }
404
416
  if (name === "install_app") {
405
417
  const { platform, appPath, deviceId } = args;
406
- const res = await ToolsInteract.installAppHandler({ platform, appPath, deviceId });
418
+ const res = await ToolsManage.installAppHandler({ platform, appPath, deviceId });
407
419
  const response = {
408
420
  device: res.device,
409
421
  installed: res.installed,
@@ -412,6 +424,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
412
424
  };
413
425
  return wrapResponse(response);
414
426
  }
427
+ if (name === "build_app") {
428
+ const { platform, projectPath, variant } = args;
429
+ const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant });
430
+ return wrapResponse(res);
431
+ }
432
+ if (name === 'build_and_install') {
433
+ const { platform, projectPath, deviceId, timeout } = args;
434
+ const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout });
435
+ // res: { ndjson, result }
436
+ return {
437
+ content: [
438
+ { type: 'text', text: res.ndjson },
439
+ { type: 'text', text: JSON.stringify(res.result, null, 2) }
440
+ ]
441
+ };
442
+ }
415
443
  if (name === "get_logs") {
416
444
  const { platform, appId, deviceId, lines } = args;
417
445
  const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines });
@@ -424,7 +452,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
424
452
  }
425
453
  if (name === "list_devices") {
426
454
  const { platform, appId } = (args || {});
427
- const res = await ToolsObserve.listDevicesHandler({ platform, appId });
455
+ const res = await ToolsManage.listDevicesHandler({ platform, appId });
428
456
  return wrapResponse(res);
429
457
  }
430
458
  if (name === "capture_screenshot") {
@@ -439,37 +467,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
439
467
  }
440
468
  if (name === "get_ui_tree") {
441
469
  const { platform, deviceId } = args;
442
- const res = await (platform === 'android' ? new AndroidObserve().getUITree(deviceId) : new iOSObserve().getUITree(deviceId));
470
+ const res = await ToolsObserve.getUITreeHandler({ platform, deviceId });
443
471
  return wrapResponse(res);
444
472
  }
445
473
  if (name === "get_current_screen") {
446
474
  const { deviceId } = (args || {});
447
- const res = await new AndroidObserve().getCurrentScreen(deviceId);
475
+ const res = await ToolsObserve.getCurrentScreenHandler({ deviceId });
448
476
  return wrapResponse(res);
449
477
  }
450
478
  if (name === "wait_for_element") {
451
479
  const { platform, text, timeout, deviceId } = (args || {});
452
- const res = await (platform === 'android' ? new AndroidInteract().waitForElement(text, timeout, deviceId) : new iOSInteract().waitForElement(text, timeout, deviceId));
480
+ const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId });
453
481
  return wrapResponse(res);
454
482
  }
455
483
  if (name === "tap") {
456
484
  const { platform, x, y, deviceId } = (args || {});
457
- const res = await (platform === 'android' ? new AndroidInteract().tap(x, y, deviceId) : new iOSInteract().tap(x, y, deviceId));
485
+ const res = await ToolsInteract.tapHandler({ platform, x, y, deviceId });
458
486
  return wrapResponse(res);
459
487
  }
460
488
  if (name === "swipe") {
461
489
  const { x1, y1, x2, y2, duration, deviceId } = (args || {});
462
- const res = await new AndroidInteract().swipe(x1, y1, x2, y2, duration, deviceId);
490
+ const res = await ToolsInteract.swipeHandler({ x1, y1, x2, y2, duration, deviceId });
463
491
  return wrapResponse(res);
464
492
  }
465
493
  if (name === "type_text") {
466
494
  const { text, deviceId } = (args || {});
467
- const res = await new AndroidInteract().typeText(text, deviceId);
495
+ const res = await ToolsInteract.typeTextHandler({ text, deviceId });
468
496
  return wrapResponse(res);
469
497
  }
470
498
  if (name === "press_back") {
471
499
  const { deviceId } = (args || {});
472
- const res = await new AndroidInteract().pressBack(deviceId);
500
+ const res = await ToolsInteract.pressBackHandler({ deviceId });
473
501
  return wrapResponse(res);
474
502
  }
475
503
  if (name === 'start_log_stream') {