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.
- package/README.md +20 -5
- package/dist/android/diagnostics.js +24 -0
- package/dist/android/interact.js +1 -145
- package/dist/android/manage.js +162 -0
- package/dist/android/observe.js +133 -88
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +137 -147
- package/dist/ios/interact.js +4 -175
- package/dist/ios/manage.js +169 -0
- package/dist/ios/observe.js +129 -13
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +138 -124
- package/dist/server.js +45 -17
- package/dist/tools/interact.js +21 -71
- package/dist/tools/manage.js +180 -0
- package/dist/tools/observe.js +23 -69
- package/dist/tools/run.js +180 -0
- package/dist/utils/diagnostics.js +25 -0
- package/docs/CHANGELOG.md +14 -0
- package/eslint.config.js +22 -1
- package/package.json +8 -5
- package/scripts/check-idb.js +83 -0
- package/scripts/check-idb.ts +73 -0
- package/scripts/idb-helper.ts +76 -0
- package/scripts/install-idb.js +88 -0
- package/scripts/install-idb.ts +90 -0
- package/scripts/run-ios-smoke.ts +34 -0
- package/scripts/run-ios-ui-tree-tap.ts +33 -0
- package/src/android/diagnostics.ts +23 -0
- package/src/android/interact.ts +2 -155
- package/src/android/manage.ts +157 -0
- package/src/android/observe.ts +129 -97
- package/src/android/utils.ts +147 -149
- package/src/ios/interact.ts +5 -181
- package/src/ios/manage.ts +164 -0
- package/src/ios/observe.ts +130 -14
- package/src/ios/utils.ts +127 -128
- package/src/server.ts +47 -17
- package/src/tools/interact.ts +23 -62
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +24 -74
- package/src/types.ts +9 -0
- package/src/utils/diagnostics.ts +36 -0
- package/test/device/README.md +49 -0
- package/test/device/index.ts +27 -0
- package/test/device/manage/run-build-install-ios.ts +82 -0
- package/test/{integration → device/manage}/run-install-android.ts +4 -4
- package/test/{integration → device/manage}/run-install-ios.ts +4 -4
- package/test/{integration → device/observe}/logstream-real.ts +5 -4
- package/test/{integration → device/utils}/test-dist.ts +2 -2
- package/test/unit/index.ts +10 -6
- package/test/unit/manage/build.test.ts +83 -0
- package/test/unit/manage/build_and_install.test.ts +134 -0
- package/test/unit/manage/diagnostics.test.ts +85 -0
- package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
- package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
- package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
- package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
- package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
- package/tsconfig.json +2 -1
- package/test/integration/index.ts +0 -8
- package/test/integration/test-dist.mjs +0 -41
- /package/test/{integration → device/interact}/run-real-test.ts +0 -0
- /package/test/{integration → device/interact}/smoke-test.ts +0 -0
- /package/test/{integration → device/manage}/install.integration.ts +0 -0
- /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
- /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
package/dist/ios/run.js
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
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(
|
|
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
|
|
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: ${
|
|
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"),
|
|
71
|
-
|
|
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(
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
500
|
+
const res = await ToolsInteract.pressBackHandler({ deviceId });
|
|
473
501
|
return wrapResponse(res);
|
|
474
502
|
}
|
|
475
503
|
if (name === 'start_log_stream') {
|