@srmorete/mobile-device-mcp 0.1.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/cli.js +2 -0
  4. package/drivers/android/app-debug-androidTest.apk +0 -0
  5. package/drivers/android/app-debug.apk +0 -0
  6. package/drivers/ios/Debug-iphonesimulator/FlyingFox.o +0 -0
  7. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/Project/arm64-apple-ios-simulator.swiftsourceinfo +0 -0
  8. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/Project/x86_64-apple-ios-simulator.swiftsourceinfo +0 -0
  9. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/arm64-apple-ios-simulator.abi.json +9 -0
  10. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  11. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/arm64-apple-ios-simulator.swiftmodule +0 -0
  12. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/x86_64-apple-ios-simulator.abi.json +9 -0
  13. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  14. package/drivers/ios/Debug-iphonesimulator/FlyingFox.swiftmodule/x86_64-apple-ios-simulator.swiftmodule +0 -0
  15. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.o +0 -0
  16. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/Project/arm64-apple-ios-simulator.swiftsourceinfo +0 -0
  17. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/Project/x86_64-apple-ios-simulator.swiftsourceinfo +0 -0
  18. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/arm64-apple-ios-simulator.abi.json +9 -0
  19. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  20. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/arm64-apple-ios-simulator.swiftmodule +0 -0
  21. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/x86_64-apple-ios-simulator.abi.json +9 -0
  22. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  23. package/drivers/ios/Debug-iphonesimulator/FlyingSocks.swiftmodule/x86_64-apple-ios-simulator.swiftmodule +0 -0
  24. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.app/Info.plist +0 -0
  25. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.app/PkgInfo +1 -0
  26. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.app/UITreeServerApp +0 -0
  27. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.app/UITreeServerApp.debug.dylib +0 -0
  28. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.app/_CodeSignature/CodeResources +128 -0
  29. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.app/__preview.dylib +0 -0
  30. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/Project/arm64-apple-ios-simulator.swiftsourceinfo +0 -0
  31. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/Project/x86_64-apple-ios-simulator.swiftsourceinfo +0 -0
  32. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/arm64-apple-ios-simulator.abi.json +9 -0
  33. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  34. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/arm64-apple-ios-simulator.swiftmodule +0 -0
  35. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/x86_64-apple-ios-simulator.abi.json +9 -0
  36. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  37. package/drivers/ios/Debug-iphonesimulator/UITreeServerApp.swiftmodule/x86_64-apple-ios-simulator.swiftmodule +0 -0
  38. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/Testing.framework/Info.plist +0 -0
  39. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/Testing.framework/Testing +0 -0
  40. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/Testing.framework/_CodeSignature/CodeResources +168 -0
  41. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/Testing.framework/version.plist +18 -0
  42. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/Info.plist +0 -0
  43. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/XCTAutomationSupport +0 -0
  44. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/_CodeSignature/CodeResources +113 -0
  45. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/version.plist +18 -0
  46. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTest.framework/Info.plist +0 -0
  47. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTest.framework/XCTest +0 -0
  48. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTest.framework/_CodeSignature/CodeResources +817 -0
  49. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTest.framework/version.plist +18 -0
  50. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestCore.framework/Info.plist +0 -0
  51. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestCore.framework/XCTestCore +0 -0
  52. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestCore.framework/_CodeSignature/CodeResources +113 -0
  53. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestCore.framework/version.plist +18 -0
  54. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestSupport.framework/Info.plist +0 -0
  55. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestSupport.framework/XCTestSupport +0 -0
  56. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestSupport.framework/_CodeSignature/CodeResources +113 -0
  57. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCTestSupport.framework/version.plist +18 -0
  58. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUIAutomation.framework/Info.plist +0 -0
  59. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUIAutomation.framework/XCUIAutomation +0 -0
  60. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUIAutomation.framework/_CodeSignature/CodeResources +432 -0
  61. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUIAutomation.framework/version.plist +18 -0
  62. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUnit.framework/Info.plist +0 -0
  63. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUnit.framework/XCUnit +0 -0
  64. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUnit.framework/_CodeSignature/CodeResources +113 -0
  65. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/XCUnit.framework/version.plist +18 -0
  66. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Frameworks/libXCTestSwiftSupport.dylib +0 -0
  67. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/Info.plist +254 -0
  68. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/PkgInfo +1 -0
  69. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/PlugIns/UITreeServerUITests.xctest/Info.plist +0 -0
  70. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/PlugIns/UITreeServerUITests.xctest/UITreeServerUITests +0 -0
  71. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/PlugIns/UITreeServerUITests.xctest/_CodeSignature/CodeResources +101 -0
  72. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/UITreeServerUITests-Runner +0 -0
  73. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests-Runner.app/_CodeSignature/CodeResources +458 -0
  74. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/Project/arm64-apple-ios-simulator.swiftsourceinfo +0 -0
  75. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/Project/x86_64-apple-ios-simulator.swiftsourceinfo +0 -0
  76. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/arm64-apple-ios-simulator.abi.json +9 -0
  77. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  78. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/arm64-apple-ios-simulator.swiftmodule +0 -0
  79. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/x86_64-apple-ios-simulator.abi.json +9 -0
  80. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  81. package/drivers/ios/Debug-iphonesimulator/UITreeServerUITests.swiftmodule/x86_64-apple-ios-simulator.swiftmodule +0 -0
  82. package/drivers/ios/UITreeServerUITests_iphonesimulator26.2-arm64-x86_64.xctestrun +135 -0
  83. package/drivers/ios/UITreeServerUITests_iphonesimulator26.2-arm64.xctestrun +135 -0
  84. package/package.json +32 -0
  85. package/src/filter/filter.ts +393 -0
  86. package/src/filter/index.ts +42 -0
  87. package/src/filter/types.ts +70 -0
  88. package/src/server/bootstrap.ts +367 -0
  89. package/src/server/devices.ts +262 -0
  90. package/src/server/index.ts +41 -0
  91. package/src/server/ports.ts +190 -0
  92. package/src/server/proxy.ts +119 -0
  93. package/src/server/tools.ts +303 -0
  94. package/src/server/types.ts +22 -0
@@ -0,0 +1,367 @@
1
+ import { readdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { randomBytes } from "crypto";
4
+ import type { Subprocess } from "bun";
5
+ import type { RegisteredDevice, DeviceType } from "./types.js";
6
+ import {
7
+ getDevice,
8
+ setDevice,
9
+ removeDevice,
10
+ cleanupDevice,
11
+ detectPlatform,
12
+ } from "./devices.js";
13
+ import {
14
+ allocateAndroidPort,
15
+ allocateIOSPort,
16
+ addPendingPort,
17
+ releasePendingPort,
18
+ unlockPort,
19
+ cleanupStalePorts,
20
+ killPortListener,
21
+ } from "./ports.js";
22
+
23
+ const HEALTH_POLL_INTERVAL = 500;
24
+ const HEALTH_TIMEOUT = 45_000;
25
+
26
+ // Drivers root relative to project
27
+ const PROJECT_ROOT = join(import.meta.dir, "..", "..");
28
+ const DRIVERS_ANDROID = join(PROJECT_ROOT, "drivers", "android");
29
+ const DRIVERS_IOS_SIM = join(PROJECT_ROOT, "drivers", "ios");
30
+ const DRIVERS_IOS_DEVICE = join(PROJECT_ROOT, "drivers", "ios-device");
31
+
32
+ // ── Promise chains for per-device serialization ──
33
+ const bootstrapChains = new Map<string, Promise<void>>();
34
+
35
+ export async function ensureDevice(deviceId: string): Promise<RegisteredDevice> {
36
+ // Fast path: already registered — but verify server is still alive
37
+ const existing = getDevice(deviceId);
38
+ if (existing) {
39
+ if (existing.serverProcess.exitCode !== null) {
40
+ const old = removeDevice(deviceId);
41
+ if (old) await cleanupDevice(old);
42
+ } else {
43
+ return existing;
44
+ }
45
+ }
46
+
47
+ // Serialize bootstrap per device
48
+ const chain = (bootstrapChains.get(deviceId) ?? Promise.resolve()).catch(() => {});
49
+ const next = chain.then(async () => {
50
+ // Re-check after waiting (a prior bootstrap may have succeeded)
51
+ const found = getDevice(deviceId);
52
+ if (found) return;
53
+ await bootstrapDevice(deviceId);
54
+ });
55
+ bootstrapChains.set(deviceId, next);
56
+ await next;
57
+
58
+ const device = getDevice(deviceId);
59
+ if (!device) throw new Error(`Bootstrap failed for device ${deviceId}`);
60
+ return device;
61
+ }
62
+
63
+ async function bootstrapDevice(deviceId: string): Promise<void> {
64
+ const { platform, deviceType } = await detectPlatform(deviceId);
65
+
66
+ if (platform === "android") {
67
+ await bootstrapAndroid(deviceId);
68
+ } else {
69
+ await bootstrapIOS(deviceId, deviceType!);
70
+ }
71
+ }
72
+
73
+ // ── Android bootstrap ──
74
+
75
+ async function bootstrapAndroid(deviceId: string): Promise<void> {
76
+ // Clean up stale state from a previous MCP instance (e.g. after Claude Code restart)
77
+ await cleanupStaleAndroid(deviceId);
78
+
79
+ const port = await allocateAndroidPort();
80
+ const authToken = randomBytes(32).toString("hex");
81
+ addPendingPort(port);
82
+
83
+ let serverProcess: Subprocess | undefined;
84
+ let cdpPort: number | undefined;
85
+ try {
86
+ // Install APKs
87
+ const apks = readdirSync(DRIVERS_ANDROID).filter((f) => f.endsWith(".apk"));
88
+ for (const apk of apks) {
89
+ const proc = Bun.spawn(["adb", "-s", deviceId, "install", "-r", "-g", join(DRIVERS_ANDROID, apk)], {
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ });
93
+ await proc.exited;
94
+ }
95
+
96
+ // Forward host port to device server
97
+ await runAdb(deviceId, "forward", `tcp:${port}`, `tcp:${port}`);
98
+
99
+ // CDP forwarding: route device:9222 → host:cdpPort → device Chrome abstract socket
100
+ // ADB daemon bypasses SELinux app-to-app restrictions (CdpBridge.kt couldn't)
101
+ cdpPort = await setupCdpForwarding(deviceId);
102
+
103
+ // Write auth token to device file (avoids exposing in process args / ps output)
104
+ await runAdb(deviceId, "shell", `echo -n ${authToken} > /data/local/tmp/.mds_auth_${port}`);
105
+
106
+ // Spawn instrumentation
107
+ serverProcess = Bun.spawn(
108
+ [
109
+ "adb", "-s", deviceId, "shell", "am", "instrument", "-w", "-r",
110
+ "-e", "class", "dev.uitreeserver.UITreeServer#startServer",
111
+ "-e", "port", String(port),
112
+ "dev.uitreeserver.test/androidx.test.runner.AndroidJUnitRunner",
113
+ ],
114
+ { stdout: "ignore", stderr: "ignore" },
115
+ );
116
+
117
+ // Health poll
118
+ await healthPoll(port, serverProcess);
119
+
120
+ // Register
121
+ setDevice({
122
+ id: deviceId,
123
+ platform: "android",
124
+ port,
125
+ authToken,
126
+ serverProcess,
127
+ });
128
+ } catch (err) {
129
+ // Cleanup on failure
130
+ if (serverProcess) {
131
+ try { serverProcess.kill(9); } catch { /* */ }
132
+ }
133
+ try { await runAdb(deviceId, "forward", "--remove", `tcp:${port}`); } catch { /* */ }
134
+ if (cdpPort) {
135
+ try { await runAdb(deviceId, "forward", "--remove", `tcp:${cdpPort}`); } catch { /* */ }
136
+ }
137
+ try { await runAdb(deviceId, "reverse", "--remove", "tcp:9222"); } catch { /* */ }
138
+ try { await runAdb(deviceId, "shell", `rm -f /data/local/tmp/.mds_auth_${port}`); } catch { /* */ }
139
+ throw err;
140
+ } finally {
141
+ releasePendingPort(port);
142
+ }
143
+ }
144
+
145
+ async function cleanupStaleAndroid(deviceId: string): Promise<void> {
146
+ // Kill any leftover instrumentation from a previous MCP instance
147
+ await runAdb(deviceId, "shell", "am", "force-stop", "dev.uitreeserver.test");
148
+ // Remove stale CDP reverse (device:9222 → host)
149
+ try { await runAdb(deviceId, "reverse", "--remove", "tcp:9222"); } catch { /* no reverse to remove */ }
150
+ // Remove all stale adb forwards for this device
151
+ try {
152
+ const proc = Bun.spawn(["adb", "-s", deviceId, "forward", "--list"], { stdout: "pipe", stderr: "pipe" });
153
+ const output = await new Response(proc.stdout).text();
154
+ await proc.exited;
155
+ for (const line of output.split("\n")) {
156
+ // adb forward --list returns ALL devices; only remove ours
157
+ if (!line.startsWith(deviceId + " ")) continue;
158
+ const match = line.match(/^\S+\s+(tcp:\d+)/);
159
+ if (match) {
160
+ await runAdb(deviceId, "forward", "--remove", match[1]);
161
+ }
162
+ }
163
+ } catch { /* adb not available */ }
164
+ // Clean up any leftover auth token files
165
+ await runAdb(deviceId, "shell", "rm -f /data/local/tmp/.mds_auth_*");
166
+ }
167
+
168
+ async function setupCdpForwarding(deviceId: string): Promise<number> {
169
+ // Let ADB pick a free host port (tcp:0) that tunnels to Chrome's abstract socket
170
+ const fwdProc = Bun.spawn(
171
+ ["adb", "-s", deviceId, "forward", "tcp:0", "localabstract:chrome_devtools_remote"],
172
+ { stdout: "pipe", stderr: "pipe" },
173
+ );
174
+ const cdpPort = parseInt((await new Response(fwdProc.stdout).text()).trim(), 10);
175
+ await fwdProc.exited;
176
+ if (isNaN(cdpPort)) {
177
+ throw new Error("Failed to allocate CDP forward port");
178
+ }
179
+ // Reverse: device:9222 → host:cdpPort (so on-device CdpClient reaches Chrome)
180
+ try {
181
+ await runAdb(deviceId, "reverse", "tcp:9222", `tcp:${cdpPort}`);
182
+ } catch (err) {
183
+ try { await runAdb(deviceId, "forward", "--remove", `tcp:${cdpPort}`); } catch { /* */ }
184
+ throw err;
185
+ }
186
+ return cdpPort;
187
+ }
188
+
189
+ async function runAdb(deviceId: string, ...args: string[]): Promise<void> {
190
+ const proc = Bun.spawn(["adb", "-s", deviceId, ...args], {
191
+ stdout: "pipe",
192
+ stderr: "pipe",
193
+ });
194
+ await proc.exited;
195
+ }
196
+
197
+ // ── iOS bootstrap ──
198
+
199
+ async function bootstrapIOS(deviceId: string, deviceType: DeviceType): Promise<void> {
200
+ // Clean up stale state from a previous MCP instance (e.g. after Claude Code restart)
201
+ await cleanupStalePorts();
202
+
203
+ const port = await allocateIOSPort();
204
+ const authToken = randomBytes(32).toString("hex");
205
+ addPendingPort(port);
206
+
207
+ let serverProcess: Subprocess | undefined;
208
+ let tunnelProcess: Subprocess | undefined;
209
+ try {
210
+ if (deviceType === "simulator") {
211
+ serverProcess = await bootstrapIOSSimulator(deviceId, port, authToken);
212
+ } else {
213
+ const result = await bootstrapIOSDevice(deviceId, port, authToken);
214
+ serverProcess = result.serverProcess;
215
+ tunnelProcess = result.tunnelProcess;
216
+ }
217
+
218
+ // Health poll
219
+ await healthPoll(port, serverProcess);
220
+
221
+ // Register
222
+ setDevice({
223
+ id: deviceId,
224
+ platform: "ios",
225
+ deviceType,
226
+ port,
227
+ authToken,
228
+ serverProcess,
229
+ tunnelProcess,
230
+ });
231
+ } catch (err) {
232
+ // Cleanup on failure
233
+ if (serverProcess) {
234
+ try { serverProcess.kill(9); } catch { /* */ }
235
+ }
236
+ if (tunnelProcess) {
237
+ try { tunnelProcess.kill(9); } catch { /* */ }
238
+ }
239
+ // For simulators, the spawn handle kill above doesn't kill the real server —
240
+ // kill whatever is actually listening on the port to prevent orphans.
241
+ if (deviceType === "simulator") {
242
+ await killPortListener(port);
243
+ }
244
+ unlockPort(port);
245
+ throw err;
246
+ } finally {
247
+ releasePendingPort(port);
248
+ }
249
+ }
250
+
251
+ // ── iOS simulator: simctl install + spawn (bypasses xcodebuild orchestration) ──
252
+
253
+ async function bootstrapIOSSimulator(deviceId: string, port: number, authToken: string): Promise<Subprocess> {
254
+ const buildDir = join(DRIVERS_IOS_SIM, "Debug-iphonesimulator");
255
+ const runnerApp = readdirSync(buildDir).find((f) => f.endsWith("-Runner.app"));
256
+ if (!runnerApp) {
257
+ throw new Error("No Runner.app found in iOS simulator drivers");
258
+ }
259
+ const runnerAppPath = join(buildDir, runnerApp);
260
+ const binaryName = runnerApp.replace(/\.app$/, "");
261
+
262
+ // Read bundle ID from Info.plist
263
+ const plistProc = Bun.spawn(
264
+ ["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier", join(runnerAppPath, "Info.plist")],
265
+ { stdout: "pipe", stderr: "pipe" },
266
+ );
267
+ const bundleId = (await new Response(plistProc.stdout).text()).trim();
268
+ await plistProc.exited;
269
+ if (!bundleId) {
270
+ throw new Error("Could not read bundle ID from Runner.app Info.plist");
271
+ }
272
+
273
+ // Install the app on the simulator
274
+ const installProc = Bun.spawn(
275
+ ["xcrun", "simctl", "install", deviceId, runnerAppPath],
276
+ { stdout: "pipe", stderr: "pipe" },
277
+ );
278
+ const installExit = await installProc.exited;
279
+ if (installExit !== 0) {
280
+ const stderr = await new Response(installProc.stderr).text();
281
+ throw new Error(`simctl install failed: ${stderr}`);
282
+ }
283
+
284
+ // Get installed app container path
285
+ const containerProc = Bun.spawn(
286
+ ["xcrun", "simctl", "get_app_container", deviceId, bundleId],
287
+ { stdout: "pipe", stderr: "pipe" },
288
+ );
289
+ const containerPath = (await new Response(containerProc.stdout).text()).trim();
290
+ await containerProc.exited;
291
+ if (!containerPath) {
292
+ throw new Error("Could not get app container path after install");
293
+ }
294
+
295
+ // Spawn the runner binary inside the simulator (no foreground, no SpringBoard)
296
+ return Bun.spawn(
297
+ ["xcrun", "simctl", "spawn", deviceId, join(containerPath, binaryName)],
298
+ {
299
+ stdout: "ignore",
300
+ stderr: "ignore",
301
+ env: {
302
+ ...process.env,
303
+ SIMCTL_CHILD_PORT: String(port),
304
+ SIMCTL_CHILD_AUTH_TOKEN: authToken,
305
+ },
306
+ },
307
+ );
308
+ }
309
+
310
+ // ── iOS real device: xcodebuild (still required for testmanagerd handshake) ──
311
+
312
+ async function bootstrapIOSDevice(
313
+ deviceId: string, port: number, authToken: string,
314
+ ): Promise<{ serverProcess: Subprocess; tunnelProcess: Subprocess }> {
315
+ const xctestrunFile = readdirSync(DRIVERS_IOS_DEVICE).find((f) => f.endsWith(".xctestrun"));
316
+ if (!xctestrunFile) {
317
+ throw new Error("No .xctestrun driver found for iOS device");
318
+ }
319
+
320
+ const serverProcess = Bun.spawn(
321
+ [
322
+ "xcodebuild", "test-without-building",
323
+ "-xctestrun", join(DRIVERS_IOS_DEVICE, xctestrunFile),
324
+ "-destination", `platform=iOS,id=${deviceId}`,
325
+ "-parallel-testing-enabled", "NO",
326
+ "-allowProvisioningUpdates",
327
+ ],
328
+ {
329
+ stdout: "ignore",
330
+ stderr: "ignore",
331
+ env: {
332
+ ...process.env,
333
+ TEST_RUNNER_PORT: String(port),
334
+ TEST_RUNNER_AUTH_TOKEN: authToken,
335
+ },
336
+ },
337
+ );
338
+
339
+ const tunnelProcess = Bun.spawn(
340
+ ["iproxy", String(port), String(port), "-u", deviceId, "-l", "127.0.0.1"],
341
+ { stdout: "ignore", stderr: "ignore" },
342
+ );
343
+
344
+ return { serverProcess, tunnelProcess };
345
+ }
346
+
347
+ // ── Health poll ──
348
+
349
+ async function healthPoll(port: number, serverProcess: Subprocess): Promise<void> {
350
+ const deadline = Date.now() + HEALTH_TIMEOUT;
351
+ while (Date.now() < deadline) {
352
+ try {
353
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
354
+ if (res.ok) return;
355
+ } catch {
356
+ // Connection not ready yet
357
+ }
358
+ // Check if the process died
359
+ if (serverProcess.exitCode !== null) {
360
+ throw new Error(`Server process exited with code ${serverProcess.exitCode} before becoming healthy`);
361
+ }
362
+ await Bun.sleep(HEALTH_POLL_INTERVAL);
363
+ }
364
+ // Timeout: kill and throw
365
+ try { serverProcess.kill(9); } catch { /* */ }
366
+ throw new Error(`Health check timed out after ${HEALTH_TIMEOUT}ms on port ${port}`);
367
+ }
@@ -0,0 +1,262 @@
1
+ import { unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import type { RegisteredDevice, DiscoveredDevice, Platform, DeviceType } from "./types.js";
5
+ import { killPortListener } from "./ports.js";
6
+
7
+ const LOCK_DIR = join(homedir(), ".mdms", "ports");
8
+
9
+ // ── Device registry ──
10
+
11
+ const registry = new Map<string, RegisteredDevice>();
12
+
13
+ export function getDevice(id: string): RegisteredDevice | undefined {
14
+ return registry.get(id);
15
+ }
16
+
17
+ export function setDevice(device: RegisteredDevice): void {
18
+ registry.set(device.id, device);
19
+ }
20
+
21
+ export function removeDevice(id: string): RegisteredDevice | undefined {
22
+ const device = registry.get(id);
23
+ if (device) registry.delete(id);
24
+ return device;
25
+ }
26
+
27
+ export function allDevices(): RegisteredDevice[] {
28
+ return Array.from(registry.values());
29
+ }
30
+
31
+ // ── Discovery ──
32
+
33
+ async function runCommand(cmd: string[]): Promise<string> {
34
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
35
+ const text = await new Response(proc.stdout).text();
36
+ await proc.exited;
37
+ return text;
38
+ }
39
+
40
+ async function discoverAndroid(): Promise<DiscoveredDevice[]> {
41
+ try {
42
+ const output = await runCommand(["adb", "devices", "-l"]);
43
+ const devices: DiscoveredDevice[] = [];
44
+ for (const line of output.split("\n")) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed || trimmed.startsWith("List of")) continue;
47
+ // Format: <serial> <state> <info...>
48
+ const parts = trimmed.split(/\s+/);
49
+ if (parts.length < 2) continue;
50
+ const id = parts[0];
51
+ const state = parts[1];
52
+ let name = id;
53
+ for (const part of parts.slice(2)) {
54
+ if (part.startsWith("model:")) {
55
+ name = part.slice("model:".length);
56
+ break;
57
+ }
58
+ }
59
+ devices.push({ id, platform: "android", name, state });
60
+ }
61
+ return devices;
62
+ } catch (err) {
63
+ console.error("Android discovery failed:", err);
64
+ return [];
65
+ }
66
+ }
67
+
68
+ async function discoverIOSSimulators(): Promise<DiscoveredDevice[]> {
69
+ try {
70
+ const output = await runCommand(["xcrun", "simctl", "list", "devices", "booted", "-j"]);
71
+ const json = JSON.parse(output);
72
+ const devices: DiscoveredDevice[] = [];
73
+ const runtimes = json.devices || {};
74
+ for (const runtime of Object.keys(runtimes)) {
75
+ for (const device of runtimes[runtime]) {
76
+ if (device.state !== "Booted") continue;
77
+ devices.push({
78
+ id: device.udid,
79
+ platform: "ios",
80
+ name: device.name,
81
+ state: device.state,
82
+ deviceType: "simulator",
83
+ });
84
+ }
85
+ }
86
+ return devices;
87
+ } catch (err) {
88
+ console.error("iOS simulator discovery failed:", err);
89
+ return [];
90
+ }
91
+ }
92
+
93
+ async function discoverIOSRealDevices(): Promise<DiscoveredDevice[]> {
94
+ try {
95
+ const output = await runCommand(["xcrun", "devicectl", "list", "devices", "--json-output", "/dev/stdout"]);
96
+ const json = JSON.parse(output);
97
+ const devices: DiscoveredDevice[] = [];
98
+ const result = json.result?.devices || [];
99
+ for (const device of result) {
100
+ // Only include USB-connected devices
101
+ if (device.connectionProperties?.transportType !== "wired") continue;
102
+ devices.push({
103
+ id: device.identifier,
104
+ platform: "ios",
105
+ name: device.deviceProperties?.name || device.identifier,
106
+ state: "connected",
107
+ deviceType: "device",
108
+ });
109
+ }
110
+ return devices;
111
+ } catch (err) {
112
+ console.error("iOS real device discovery failed:", err);
113
+ return [];
114
+ }
115
+ }
116
+
117
+ export async function discoverDevices(): Promise<DiscoveredDevice[]> {
118
+ const [android, iosSim, iosDevice] = await Promise.all([
119
+ discoverAndroid(),
120
+ discoverIOSSimulators(),
121
+ discoverIOSRealDevices(),
122
+ ]);
123
+ return [...android, ...iosSim, ...iosDevice];
124
+ }
125
+
126
+ export type DetectedPlatformInfo = {
127
+ platform: Platform;
128
+ deviceType?: DeviceType;
129
+ };
130
+
131
+ export async function detectPlatform(deviceId: string): Promise<DetectedPlatformInfo> {
132
+ // Check Android first
133
+ try {
134
+ const output = await runCommand(["adb", "devices"]);
135
+ for (const line of output.split("\n")) {
136
+ const serial = line.split("\t")[0];
137
+ if (serial === deviceId) {
138
+ return { platform: "android" };
139
+ }
140
+ }
141
+ } catch { /* adb not available */ }
142
+
143
+ // Check iOS simulators
144
+ try {
145
+ const output = await runCommand(["xcrun", "simctl", "list", "devices", "booted", "-j"]);
146
+ const json = JSON.parse(output);
147
+ const runtimes = json.devices || {};
148
+ for (const runtime of Object.keys(runtimes)) {
149
+ for (const device of runtimes[runtime]) {
150
+ if (device.udid === deviceId && device.state === "Booted") {
151
+ return { platform: "ios", deviceType: "simulator" };
152
+ }
153
+ }
154
+ }
155
+ } catch { /* simctl not available */ }
156
+
157
+ // Check iOS real devices
158
+ try {
159
+ const output = await runCommand(["xcrun", "devicectl", "list", "devices", "--json-output", "/dev/stdout"]);
160
+ const json = JSON.parse(output);
161
+ const result = json.result?.devices || [];
162
+ for (const device of result) {
163
+ if (device.identifier === deviceId && device.connectionProperties?.transportType === "wired") {
164
+ return { platform: "ios", deviceType: "device" };
165
+ }
166
+ }
167
+ } catch { /* devicectl not available */ }
168
+
169
+ throw new Error(`Device ${deviceId} not found in adb, simctl, or devicectl`);
170
+ }
171
+
172
+ // ── Cleanup ──
173
+
174
+ export async function cleanupDevice(device: RegisteredDevice): Promise<void> {
175
+ // Kill server process (SIGKILL, try process group first)
176
+ try {
177
+ if (device.serverProcess.pid && device.serverProcess.pid > 0) {
178
+ process.kill(-device.serverProcess.pid, "SIGKILL");
179
+ }
180
+ } catch {
181
+ try {
182
+ device.serverProcess.kill(9);
183
+ } catch { /* already dead */ }
184
+ }
185
+
186
+ // Kill tunnel process if present (iOS real devices)
187
+ if (device.tunnelProcess) {
188
+ try {
189
+ if (device.tunnelProcess.pid && device.tunnelProcess.pid > 0) {
190
+ process.kill(-device.tunnelProcess.pid, "SIGKILL");
191
+ }
192
+ } catch {
193
+ try {
194
+ device.tunnelProcess.kill(9);
195
+ } catch { /* already dead */ }
196
+ }
197
+ }
198
+
199
+ // Platform-specific cleanup
200
+ if (device.platform === "android") {
201
+ // Force-stop the test package
202
+ const forceStop = Bun.spawn(["adb", "-s", device.id, "shell", "am", "force-stop", "dev.uitreeserver.test"], {
203
+ stdout: "ignore",
204
+ stderr: "ignore",
205
+ });
206
+ await forceStop.exited;
207
+
208
+ // Remove auth token file from device
209
+ const rmAuth = Bun.spawn(
210
+ ["adb", "-s", device.id, "shell", `rm -f /data/local/tmp/.mds_auth_${device.port}`],
211
+ { stdout: "ignore", stderr: "ignore" },
212
+ );
213
+ await rmAuth.exited;
214
+
215
+ // Remove CDP reverse (device:9222 → host)
216
+ const rmReverse = Bun.spawn(
217
+ ["adb", "-s", device.id, "reverse", "--remove", "tcp:9222"],
218
+ { stdout: "ignore", stderr: "ignore" },
219
+ );
220
+ await rmReverse.exited;
221
+
222
+ // Remove ALL ADB forwards for this device (not just registered ports)
223
+ try {
224
+ const listProc = Bun.spawn(["adb", "-s", device.id, "forward", "--list"], { stdout: "pipe", stderr: "ignore" });
225
+ const output = await new Response(listProc.stdout).text();
226
+ await listProc.exited;
227
+ const removes: Promise<number>[] = [];
228
+ for (const line of output.split("\n")) {
229
+ // adb forward --list returns ALL devices; only remove ours
230
+ if (!line.startsWith(device.id + " ")) continue;
231
+ const match = line.match(/^\S+\s+(tcp:\d+)/);
232
+ if (match) {
233
+ removes.push(
234
+ Bun.spawn(["adb", "-s", device.id, "forward", "--remove", match[1]], {
235
+ stdout: "ignore", stderr: "ignore",
236
+ }).exited,
237
+ );
238
+ }
239
+ }
240
+ await Promise.allSettled(removes);
241
+ } catch { /* adb not available */ }
242
+ } else if (device.platform === "ios") {
243
+ // For simulators, kill the actual server inside the simulator.
244
+ // serverProcess is just the simctl spawn handle — the real server
245
+ // runs under the simulator's launchd and survives handle death.
246
+ if (device.deviceType === "simulator") {
247
+ await killPortListener(device.port);
248
+ }
249
+ // Delete port lock file
250
+ try {
251
+ unlinkSync(join(LOCK_DIR, String(device.port)));
252
+ } catch { /* file may not exist */ }
253
+ }
254
+ }
255
+
256
+ export async function cleanupAll(): Promise<void> {
257
+ const devices = allDevices();
258
+ await Promise.allSettled(devices.map((device) => cleanupDevice(device)));
259
+ for (const device of devices) {
260
+ registry.delete(device.id);
261
+ }
262
+ }
@@ -0,0 +1,41 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { registerTools } from "./tools.js";
4
+ import { cleanupAll } from "./devices.js";
5
+ import pkg from "../../package.json";
6
+
7
+ // ── Shutdown ──
8
+
9
+ let shuttingDown = false;
10
+
11
+ async function shutdown(): Promise<void> {
12
+ if (shuttingDown) return;
13
+ shuttingDown = true;
14
+ console.error("Shutting down, cleaning up devices...");
15
+ await cleanupAll();
16
+ process.exit(0);
17
+ }
18
+
19
+ process.on("SIGINT", shutdown);
20
+ process.on("SIGTERM", shutdown);
21
+
22
+ // ── Main ──
23
+
24
+ async function main(): Promise<void> {
25
+ const server = new McpServer(
26
+ { name: pkg.name, version: pkg.version },
27
+ { capabilities: { tools: {} } },
28
+ );
29
+
30
+ registerTools(server);
31
+
32
+ const transport = new StdioServerTransport();
33
+ await server.connect(transport);
34
+ console.error("MCP server running on stdio");
35
+ }
36
+
37
+ main().catch(async (err) => {
38
+ console.error("Fatal error:", err);
39
+ await cleanupAll();
40
+ process.exit(1);
41
+ });