@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,190 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { allDevices } from "./devices.js";
5
+
6
+ const BASE_PORT_IOS = parseInt(process.env.MDMS_PORT_IOS || "19000", 10);
7
+ const BASE_PORT_ANDROID = parseInt(process.env.MDMS_PORT_ANDROID || "18000", 10);
8
+ const LOCK_DIR = join(homedir(), ".mdms", "ports");
9
+
10
+ // Pending ports: tracked during bootstrap, released after health check
11
+ const pendingPorts = new Set<number>();
12
+
13
+ export function addPendingPort(port: number): void {
14
+ pendingPorts.add(port);
15
+ }
16
+
17
+ export function releasePendingPort(port: number): void {
18
+ pendingPorts.delete(port);
19
+ }
20
+
21
+ function registryPorts(): Set<number> {
22
+ const ports = new Set<number>();
23
+ for (const device of allDevices()) {
24
+ ports.add(device.port);
25
+ }
26
+ return ports;
27
+ }
28
+
29
+ // ── Android port allocation ──
30
+
31
+ async function getADBForwardedPorts(): Promise<Set<number>> {
32
+ const ports = new Set<number>();
33
+ try {
34
+ const proc = Bun.spawn(["adb", "forward", "--list"], { stdout: "pipe", stderr: "pipe" });
35
+ const output = await new Response(proc.stdout).text();
36
+ await proc.exited;
37
+ // Lines: <serial> tcp:<hostPort> <deviceSpec>
38
+ // Only track the host-side port (second column)
39
+ for (const line of output.split("\n")) {
40
+ const match = line.match(/^\S+\s+tcp:(\d+)/);
41
+ if (match) {
42
+ ports.add(parseInt(match[1], 10));
43
+ }
44
+ }
45
+ } catch { /* adb not available */ }
46
+ return ports;
47
+ }
48
+
49
+ export async function allocateAndroidPort(): Promise<number> {
50
+ const adbPorts = await getADBForwardedPorts();
51
+ const regPorts = registryPorts();
52
+ const usedPorts = new Set([...pendingPorts, ...regPorts, ...adbPorts]);
53
+
54
+ let port = BASE_PORT_ANDROID;
55
+ while (usedPorts.has(port)) port++;
56
+
57
+ if (port > 65535) {
58
+ throw new Error("No available ports for Android device server");
59
+ }
60
+ return port;
61
+ }
62
+
63
+ // ── iOS port allocation ──
64
+
65
+ async function isPortListening(port: number): Promise<boolean> {
66
+ try {
67
+ const proc = Bun.spawn(["lsof", "-i", `TCP:${port}`, "-sTCP:LISTEN"], {
68
+ stdout: "pipe",
69
+ stderr: "pipe",
70
+ });
71
+ const output = await new Response(proc.stdout).text();
72
+ await proc.exited;
73
+ return output.trim().length > 0;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function ensureLockDir(): void {
80
+ mkdirSync(LOCK_DIR, { recursive: true, mode: 0o700 });
81
+ // Always verify the result is not a symlink (prevents symlink attacks)
82
+ const stat = lstatSync(LOCK_DIR);
83
+ if (stat.isSymbolicLink()) {
84
+ throw new Error(`Lock directory is a symlink — refusing to use it`);
85
+ }
86
+ }
87
+
88
+ function tryLockPort(port: number): boolean {
89
+ ensureLockDir();
90
+
91
+ const lockFile = `${LOCK_DIR}/${port}`;
92
+ try {
93
+ // Try exclusive create
94
+ writeFileSync(lockFile, String(process.pid), { flag: "wx" });
95
+ return true;
96
+ } catch {
97
+ // File exists — check if the owning process is alive
98
+ try {
99
+ const pid = parseInt(readFileSync(lockFile, "utf-8").trim(), 10);
100
+ if (isNaN(pid)) {
101
+ // Invalid content, reclaim
102
+ unlinkSync(lockFile);
103
+ writeFileSync(lockFile, String(process.pid), { flag: "wx" });
104
+ return true;
105
+ }
106
+ try {
107
+ process.kill(pid, 0); // check if alive
108
+ return false; // alive, lock fails
109
+ } catch {
110
+ // Dead process, reclaim
111
+ unlinkSync(lockFile);
112
+ writeFileSync(lockFile, String(process.pid), { flag: "wx" });
113
+ return true;
114
+ }
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+ }
120
+
121
+ export function unlockPort(port: number): void {
122
+ try {
123
+ unlinkSync(`${LOCK_DIR}/${port}`);
124
+ } catch { /* file may not exist */ }
125
+ }
126
+
127
+ export async function allocateIOSPort(): Promise<number> {
128
+ const regPorts = registryPorts();
129
+ const usedPorts = new Set([...pendingPorts, ...regPorts]);
130
+
131
+ let port = BASE_PORT_IOS;
132
+ while (port <= 65535) {
133
+ if (!usedPorts.has(port) && !(await isPortListening(port)) && tryLockPort(port)) {
134
+ return port;
135
+ }
136
+ port++;
137
+ }
138
+ throw new Error("No available ports for iOS device server");
139
+ }
140
+
141
+ // ── Stale port cleanup ──
142
+
143
+ export async function killPortListener(port: number): Promise<void> {
144
+ try {
145
+ const proc = Bun.spawn(
146
+ ["lsof", "-ti", `TCP:${port}`, "-sTCP:LISTEN"],
147
+ { stdout: "pipe", stderr: "pipe" },
148
+ );
149
+ const output = await new Response(proc.stdout).text();
150
+ await proc.exited;
151
+ for (const line of output.trim().split("\n")) {
152
+ const pid = parseInt(line.trim(), 10);
153
+ if (!isNaN(pid) && pid > 0) {
154
+ try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ }
155
+ }
156
+ }
157
+ } catch { /* lsof not available */ }
158
+ }
159
+
160
+ export async function cleanupStalePorts(): Promise<void> {
161
+ let files: string[];
162
+ try {
163
+ files = readdirSync(LOCK_DIR);
164
+ } catch {
165
+ return; // lock dir doesn't exist yet
166
+ }
167
+
168
+ for (const file of files) {
169
+ const port = parseInt(file, 10);
170
+ if (isNaN(port)) continue;
171
+
172
+ const lockFile = join(LOCK_DIR, file);
173
+ let ownerAlive = false;
174
+ try {
175
+ const pid = parseInt(readFileSync(lockFile, "utf-8").trim(), 10);
176
+ if (!isNaN(pid)) {
177
+ try {
178
+ process.kill(pid, 0);
179
+ ownerAlive = true;
180
+ } catch { /* dead */ }
181
+ }
182
+ } catch { continue; }
183
+
184
+ if (ownerAlive) continue;
185
+
186
+ // Owner MCP is dead — kill whatever is still listening on this port
187
+ await killPortListener(port);
188
+ try { unlinkSync(lockFile); } catch { /* */ }
189
+ }
190
+ }
@@ -0,0 +1,119 @@
1
+ import type { RegisteredDevice } from "./types.js";
2
+ import { ensureDevice } from "./bootstrap.js";
3
+ import { removeDevice, cleanupDevice } from "./devices.js";
4
+
5
+ function isConnectionError(err: unknown): boolean {
6
+ if (!(err instanceof Error)) return false;
7
+ const msg = err.message;
8
+ return (
9
+ msg.includes("ECONNREFUSED") ||
10
+ msg.includes("ECONNRESET") ||
11
+ msg.includes("fetch failed") ||
12
+ msg.includes("socket") ||
13
+ msg.includes("Unable to connect") ||
14
+ msg.includes("timed out") ||
15
+ msg.includes("ETIMEDOUT")
16
+ );
17
+ }
18
+
19
+ function buildUrl(port: number, path: string, queryParams?: Record<string, string | number>): string {
20
+ let url = `http://127.0.0.1:${port}${path}`;
21
+ if (queryParams && Object.keys(queryParams).length > 0) {
22
+ const parts = Object.entries(queryParams).map(
23
+ ([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`,
24
+ );
25
+ url += `?${parts.join("&")}`;
26
+ }
27
+ return url;
28
+ }
29
+
30
+ type ProxyRequest =
31
+ | { method: "GET"; path: string; binary?: false }
32
+ | { method: "GET"; path: string; binary: true }
33
+ | { method: "POST"; path: string; queryParams: Record<string, string | number> }
34
+ | { method: "POST"; path: string; body: string };
35
+
36
+ function authHeaders(device: RegisteredDevice): Record<string, string> {
37
+ return { "Authorization": `Bearer ${device.authToken}` };
38
+ }
39
+
40
+ const REQUEST_TIMEOUT = 30_000; // 30s — long enough for screenshots, short enough to detect dead servers
41
+
42
+ async function executeRequest(device: RegisteredDevice, req: ProxyRequest): Promise<Response> {
43
+ const headers = authHeaders(device);
44
+ const signal = AbortSignal.timeout(REQUEST_TIMEOUT);
45
+ if (req.method === "GET") {
46
+ return fetch(buildUrl(device.port, req.path), { headers, signal });
47
+ }
48
+ if ("queryParams" in req) {
49
+ return fetch(buildUrl(device.port, req.path, req.queryParams), { method: "POST", headers, signal });
50
+ }
51
+ // POST with body
52
+ return fetch(buildUrl(device.port, req.path), {
53
+ method: "POST",
54
+ headers: { ...headers, "Content-Type": "text/plain" },
55
+ body: req.body,
56
+ signal,
57
+ });
58
+ }
59
+
60
+ export async function proxyRequest(
61
+ deviceId: string,
62
+ req: ProxyRequest,
63
+ ): Promise<string | Buffer> {
64
+ let device = await ensureDevice(deviceId);
65
+
66
+ const attempt = async (dev: RegisteredDevice): Promise<string | Buffer> => {
67
+ const res = await executeRequest(dev, req);
68
+ if (!res.ok) {
69
+ const body = await res.text();
70
+ throw new Error(
71
+ `${req.method} ${req.path} failed: ${res.status} ${res.statusText} — ${body.slice(0, 500)}`,
72
+ );
73
+ }
74
+ if (req.method === "GET" && "binary" in req && req.binary) {
75
+ const arrayBuffer = await res.arrayBuffer();
76
+ return Buffer.from(arrayBuffer);
77
+ }
78
+ return res.text();
79
+ };
80
+
81
+ try {
82
+ return await attempt(device);
83
+ } catch (err) {
84
+ if (isConnectionError(err)) {
85
+ // Remove device, re-bootstrap, retry once
86
+ const old = removeDevice(deviceId);
87
+ if (old) await cleanupDevice(old);
88
+ device = await ensureDevice(deviceId);
89
+ return attempt(device);
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ // Convenience wrappers
96
+
97
+ export async function proxyGet(deviceId: string, path: string): Promise<string> {
98
+ return proxyRequest(deviceId, { method: "GET", path }) as Promise<string>;
99
+ }
100
+
101
+ export async function proxyGetBinary(deviceId: string, path: string): Promise<Buffer> {
102
+ return proxyRequest(deviceId, { method: "GET", path, binary: true }) as Promise<Buffer>;
103
+ }
104
+
105
+ export async function proxyPost(
106
+ deviceId: string,
107
+ path: string,
108
+ queryParams: Record<string, string | number>,
109
+ ): Promise<string> {
110
+ return proxyRequest(deviceId, { method: "POST", path, queryParams }) as Promise<string>;
111
+ }
112
+
113
+ export async function proxyPostBody(
114
+ deviceId: string,
115
+ path: string,
116
+ body: string,
117
+ ): Promise<string> {
118
+ return proxyRequest(deviceId, { method: "POST", path, body }) as Promise<string>;
119
+ }
@@ -0,0 +1,303 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
+ import { discoverDevices, getDevice } from "./devices.js";
5
+ import { proxyGet, proxyGetBinary, proxyPost, proxyPostBody } from "./proxy.js";
6
+ import { ensureDevice } from "./bootstrap.js";
7
+ import { filterUITree } from "../filter/filter.js";
8
+ import type { FilteredElement } from "../filter/types.js";
9
+
10
+ function textResult(text: string): CallToolResult {
11
+ return { content: [{ type: "text", text }] };
12
+ }
13
+
14
+ function imageResult(base64: string, mimeType: string): CallToolResult {
15
+ return { content: [{ type: "image", data: base64, mimeType }] };
16
+ }
17
+
18
+ function errorResult(message: string): CallToolResult {
19
+ return { content: [{ type: "text", text: message }], isError: true };
20
+ }
21
+
22
+ export function registerTools(server: McpServer): void {
23
+ // ── 2.2.1 list_devices ──
24
+ server.tool(
25
+ "list_devices",
26
+ "Lists available mobile devices",
27
+ {},
28
+ async () => {
29
+ const devices = await discoverDevices();
30
+ return textResult(JSON.stringify(devices, null, 2));
31
+ },
32
+ );
33
+
34
+ // ── 2.2.2 tap ──
35
+ server.tool(
36
+ "tap",
37
+ "Taps at coordinates on the device screen",
38
+ {
39
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
40
+ x: z.number(),
41
+ y: z.number(),
42
+ },
43
+ async ({ device_id, x, y }) => {
44
+ try {
45
+ const text = await proxyPost(device_id, "/tap", { x, y });
46
+ return textResult(text);
47
+ } catch (err) {
48
+ return errorResult((err as Error).message);
49
+ }
50
+ },
51
+ );
52
+
53
+ // ── 2.2.3 double_tap ──
54
+ server.tool(
55
+ "double_tap",
56
+ "Double-taps at coordinates on the device screen",
57
+ {
58
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
59
+ x: z.number(),
60
+ y: z.number(),
61
+ },
62
+ async ({ device_id, x, y }) => {
63
+ try {
64
+ const text = await proxyPost(device_id, "/doubleTap", { x, y });
65
+ return textResult(text);
66
+ } catch (err) {
67
+ return errorResult((err as Error).message);
68
+ }
69
+ },
70
+ );
71
+
72
+ // ── 2.2.4 long_press ──
73
+ server.tool(
74
+ "long_press",
75
+ "Long-presses at coordinates on the device screen",
76
+ {
77
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
78
+ x: z.number(),
79
+ y: z.number(),
80
+ duration: z.number().min(1).max(10000).optional(),
81
+ },
82
+ async ({ device_id, x, y, duration }) => {
83
+ try {
84
+ const params: Record<string, string | number> = { x, y };
85
+ if (duration !== undefined) {
86
+ params.duration = duration;
87
+ }
88
+ const text = await proxyPost(device_id, "/longPress", params);
89
+ return textResult(text);
90
+ } catch (err) {
91
+ return errorResult((err as Error).message);
92
+ }
93
+ },
94
+ );
95
+
96
+ // ── 2.2.5 scroll ──
97
+ server.tool(
98
+ "scroll",
99
+ "Scrolls on the device screen from start to end coordinates",
100
+ {
101
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
102
+ startX: z.number(),
103
+ startY: z.number(),
104
+ endX: z.number(),
105
+ endY: z.number(),
106
+ },
107
+ async ({ device_id, startX, startY, endX, endY }) => {
108
+ try {
109
+ const text = await proxyPost(device_id, "/scroll", { startX, startY, endX, endY });
110
+ return textResult(text);
111
+ } catch (err) {
112
+ return errorResult((err as Error).message);
113
+ }
114
+ },
115
+ );
116
+
117
+ // ── 2.2.6 type_text ──
118
+ server.tool(
119
+ "type_text",
120
+ "Types text on the device",
121
+ {
122
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
123
+ text: z.string().max(10_000),
124
+ },
125
+ async ({ device_id, text }) => {
126
+ try {
127
+ const result = await proxyPost(device_id, "/type", { text });
128
+ return textResult(result);
129
+ } catch (err) {
130
+ return errorResult((err as Error).message);
131
+ }
132
+ },
133
+ );
134
+
135
+ // ── 2.2.7 press_button ──
136
+ server.tool(
137
+ "press_button",
138
+ "Presses a hardware/navigation button on the device",
139
+ {
140
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
141
+ button: z.enum([
142
+ "home",
143
+ "back",
144
+ "volumeUp",
145
+ "volumeDown",
146
+ "enter",
147
+ "dpadUp",
148
+ "dpadDown",
149
+ "dpadLeft",
150
+ "dpadRight",
151
+ "dpadCenter",
152
+ ]),
153
+ },
154
+ async ({ device_id, button }) => {
155
+ try {
156
+ const text = await proxyPost(device_id, "/press", { button });
157
+ return textResult(text);
158
+ } catch (err) {
159
+ return errorResult((err as Error).message);
160
+ }
161
+ },
162
+ );
163
+
164
+ // ── 2.2.8 screenshot ──
165
+ server.tool(
166
+ "screenshot",
167
+ "Takes a screenshot of the device screen",
168
+ {
169
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
170
+ },
171
+ async ({ device_id }) => {
172
+ try {
173
+ const buf = await proxyGetBinary(device_id, "/screenshot");
174
+ if (buf.length === 0) {
175
+ return errorResult("Empty screenshot response from device");
176
+ }
177
+ const base64 = buf.toString("base64");
178
+ // Detect actual image format from magic bytes
179
+ const mimeType = (buf[0] === 0xFF && buf[1] === 0xD8) ? "image/jpeg" : "image/png";
180
+ return imageResult(base64, mimeType);
181
+ } catch (err) {
182
+ return errorResult((err as Error).message);
183
+ }
184
+ },
185
+ );
186
+
187
+ // ── 2.2.9 uitree ──
188
+ server.tool(
189
+ "uitree",
190
+ "Returns the UI element tree of the device screen",
191
+ {
192
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
193
+ search: z.string().optional(),
194
+ limit: z.number().int().min(1).optional(),
195
+ },
196
+ async ({ device_id, search, limit }) => {
197
+ try {
198
+ // 1. GET /uitree and parse
199
+ const rawJson = await proxyGet(device_id, "/uitree");
200
+ const rawTree = JSON.parse(rawJson);
201
+
202
+ // 2. Filter through UI tree filter
203
+ let elements: FilteredElement[] = filterUITree(rawTree);
204
+
205
+ // 3. Search: case-insensitive substring on text
206
+ if (search) {
207
+ const lower = search.toLowerCase();
208
+ elements = elements.filter((el) => (el.text ?? "").toLowerCase().includes(lower));
209
+ }
210
+
211
+ // 4. Limit
212
+ const total = elements.length;
213
+ let suffix = "";
214
+ if (limit !== undefined && total > limit) {
215
+ elements = elements.slice(0, limit);
216
+ suffix = `\n(showing ${limit} of ${total} elements, use 'search' to narrow results)`;
217
+ }
218
+
219
+ // 5. Serialize as JSONL
220
+ const jsonl = elements.map((el) => JSON.stringify(el)).join("\n") + suffix;
221
+ return textResult(jsonl);
222
+ } catch (err) {
223
+ return errorResult((err as Error).message);
224
+ }
225
+ },
226
+ );
227
+
228
+ // ── 2.2.10 launch_app ──
229
+ server.tool(
230
+ "launch_app",
231
+ "Launches an app on the device",
232
+ {
233
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
234
+ app_id: z.string().regex(/^[\w\-.:]{1,256}$/),
235
+ },
236
+ async ({ device_id, app_id }) => {
237
+ try {
238
+ const device = await ensureDevice(device_id);
239
+ const paramKey = device.platform === "ios" ? "bundleId" : "packageName";
240
+ const text = await proxyPost(device_id, "/launchApp", { [paramKey]: app_id });
241
+ return textResult(text);
242
+ } catch (err) {
243
+ return errorResult((err as Error).message);
244
+ }
245
+ },
246
+ );
247
+
248
+ // ── 2.2.11 terminate_app ──
249
+ server.tool(
250
+ "terminate_app",
251
+ "Terminates an app on the device",
252
+ {
253
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
254
+ app_id: z.string().regex(/^[\w\-.:]{1,256}$/),
255
+ },
256
+ async ({ device_id, app_id }) => {
257
+ try {
258
+ const device = await ensureDevice(device_id);
259
+ const paramKey = device.platform === "ios" ? "bundleId" : "packageName";
260
+ const text = await proxyPost(device_id, "/terminateApp", { [paramKey]: app_id });
261
+ return textResult(text);
262
+ } catch (err) {
263
+ return errorResult((err as Error).message);
264
+ }
265
+ },
266
+ );
267
+
268
+ // ── 2.2.12 list_apps ──
269
+ server.tool(
270
+ "list_apps",
271
+ "Lists installed apps on the device",
272
+ {
273
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
274
+ },
275
+ async ({ device_id }) => {
276
+ try {
277
+ const text = await proxyGet(device_id, "/listApps");
278
+ return textResult(text);
279
+ } catch (err) {
280
+ return errorResult((err as Error).message);
281
+ }
282
+ },
283
+ );
284
+
285
+ // ── 2.2.13 run_code ──
286
+ server.tool(
287
+ "run_code",
288
+ "Execute test automation code on the device",
289
+ {
290
+ device_id: z.string().regex(/^[\w\-.:]{1,256}$/),
291
+ code: z.string().max(100_000),
292
+ },
293
+ async ({ device_id, code }) => {
294
+ try {
295
+ const text = await proxyPostBody(device_id, "/exec", code);
296
+ return textResult(text);
297
+ } catch (err) {
298
+ return errorResult((err as Error).message);
299
+ }
300
+ },
301
+ );
302
+
303
+ }
@@ -0,0 +1,22 @@
1
+ import type { Subprocess } from "bun";
2
+
3
+ export type Platform = "android" | "ios";
4
+ export type DeviceType = "simulator" | "device";
5
+
6
+ export interface DiscoveredDevice {
7
+ id: string;
8
+ platform: Platform;
9
+ name: string;
10
+ state: string;
11
+ deviceType?: DeviceType; // iOS only
12
+ }
13
+
14
+ export interface RegisteredDevice {
15
+ id: string;
16
+ platform: Platform;
17
+ deviceType?: DeviceType;
18
+ port: number;
19
+ authToken: string;
20
+ serverProcess: Subprocess;
21
+ tunnelProcess?: Subprocess; // iOS real devices only
22
+ }